Compare commits
97 commits
Author | SHA1 | Date | |
---|---|---|---|
e6618bd432 | |||
edbc474b9a | |||
838f11aa77 | |||
c51ccda65b | |||
|
becddcbef5 | ||
|
9b701bb385 | ||
|
bbdeb4d638 | ||
|
c04b8f633b | ||
|
547c6fc17b | ||
|
12a757a954 | ||
|
c787d2d094 | ||
|
8269340a2e | ||
|
abfdfdc5d9 | ||
|
1175ce4e15 | ||
|
a99621c24f | ||
|
cb3224592d | ||
|
a440f79f42 | ||
|
4b652590b3 | ||
|
6bdfb00b69 | ||
|
6ee253704e | ||
|
d28cc59d08 | ||
|
f6c094046b | ||
|
62250f3dff | ||
|
ac40c98eb2 | ||
|
51fcac95cd | ||
|
13b3430f51 | ||
|
8cdc26ec66 | ||
|
664e9b7e67 | ||
|
a727f75bfe | ||
|
d83e457a0c | ||
|
f8dea00d8f | ||
|
9c66c7c1c1 | ||
|
0b96e4da31 | ||
|
7ed5317756 | ||
|
8d89fc8ab9 | ||
|
11ffef158b | ||
|
43fcd3dd87 | ||
|
e0cb504a55 | ||
|
ac40ec8f2c | ||
|
2b33e39169 | ||
|
147e6fd711 | ||
|
e5f6911c4e | ||
|
195d88f452 | ||
|
6bbe1ce988 | ||
|
881939ffe8 | ||
|
ef772b77ff | ||
|
2957ded7d8 | ||
|
4d6cef24a4 | ||
|
5e9e1cb163 | ||
|
5d46a42fac | ||
|
9d14a8840d | ||
|
6228bea84f | ||
|
0e2a9da895 | ||
|
fee8ae75d0 | ||
|
c266bad516 | ||
|
cf46a0ffb1 | ||
|
4e10590646 | ||
|
703411a135 | ||
|
1339ce27ad | ||
|
8908e5b1c1 | ||
|
2edde27e59 | ||
|
ceecbb4952 | ||
|
dd64ba4d42 | ||
|
0cd8ca8a55 | ||
|
a57207f80e | ||
|
c3871fa486 | ||
|
aca1f44a60 | ||
|
69534b1e43 | ||
|
0b9a1ad7af | ||
|
33361a4552 | ||
|
8dc753b815 | ||
|
f0d4f1c3b0 | ||
|
f21027895c | ||
|
2d363bc706 | ||
|
d91a6ca858 | ||
|
571101a66f | ||
|
0b008659c7 | ||
|
479cb3fd3c | ||
|
77ab2f2b18 | ||
|
7786ae1384 | ||
|
4ab17b2c96 | ||
|
eb195a171e | ||
|
65570a6ac5 | ||
|
b81d1edea2 | ||
|
bb5e2d1fb6 | ||
|
14569a9415 | ||
|
7a418c4e8a | ||
|
ac85940394 | ||
|
8add38bee4 | ||
|
3c0b41ab7f | ||
|
b193d2d1e5 | ||
|
4855aa5f5a | ||
|
dc1b414c2d | ||
|
fbf98adbc2 | ||
|
754381236c | ||
|
1aa52f1202 | ||
|
6d9b611928 |
1
.dockerignore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
node_modules
|
4
.gitignore
vendored
|
@ -1,3 +1,5 @@
|
||||||
node_modules
|
node_modules
|
||||||
_youtube
|
_youtube/*
|
||||||
|
!_youtube/.empty
|
||||||
cyp.service
|
cyp.service
|
||||||
|
passwords.json
|
||||||
|
|
1
.npmrc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
engine-strict=true
|
12
Dockerfile
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
FROM node:10
|
||||||
|
RUN apt update
|
||||||
|
RUN apt install -y jq
|
||||||
|
RUN curl -L https://yt-dl.org/downloads/latest/youtube-dl -o /usr/local/bin/youtube-dl \
|
||||||
|
&& chmod a+rx /usr/local/bin/youtube-dl
|
||||||
|
WORKDIR /cyp
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm i
|
||||||
|
COPY index.js .
|
||||||
|
COPY app ./app
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["node", "."]
|
24
Makefile
|
@ -1,19 +1,24 @@
|
||||||
LESS := $(shell npm bin)/lessc
|
LESS := $(shell npm bin)/lessc
|
||||||
|
ROLLUP := $(shell npm bin)/rollup
|
||||||
APP := app
|
APP := app
|
||||||
CSS := $(APP)/app.css
|
CSS := $(APP)/cyp.css
|
||||||
ICONS := $(APP)/js/lib/icons.js
|
JS := $(APP)/cyp.js
|
||||||
|
ICONS := $(APP)/js/icons.js
|
||||||
SYSD_USER := ~/.config/systemd/user
|
SYSD_USER := ~/.config/systemd/user
|
||||||
SERVICE := cyp.service
|
SERVICE := cyp.service
|
||||||
|
|
||||||
all: $(CSS)
|
all: $(CSS) $(JS)
|
||||||
|
|
||||||
icons: $(ICONS)
|
icons: $(ICONS)
|
||||||
|
|
||||||
$(ICONS): $(APP)/icons/*
|
$(ICONS): $(APP)/icons/*
|
||||||
$(APP)/svg2js.sh $(APP)/icons > $@
|
$(APP)/svg2js.sh $(APP)/icons > $@
|
||||||
|
|
||||||
$(CSS): $(APP)/css/*
|
$(JS): $(APP)/js/* $(APP)/js/elements/*
|
||||||
$(LESS) $(APP)/css/app.less > $@
|
$(ROLLUP) -i $(APP)/js/cyp.js > $@
|
||||||
|
|
||||||
|
$(CSS): $(APP)/css/* $(APP)/css/elements/*
|
||||||
|
$(LESS) -x $(APP)/css/cyp.less > $@
|
||||||
|
|
||||||
service: $(SERVICE)
|
service: $(SERVICE)
|
||||||
systemctl --user enable $(PWD)/$(SERVICE)
|
systemctl --user enable $(PWD)/$(SERVICE)
|
||||||
|
@ -25,7 +30,12 @@ watch: all
|
||||||
while inotifywait -e MODIFY -r $(APP)/css $(APP)/js ; do make $^ ; done
|
while inotifywait -e MODIFY -r $(APP)/css $(APP)/js ; do make $^ ; done
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
systemctl --user disable $(SERVICE)
|
rm -f $(SERVICE) $(CSS) $(JS)
|
||||||
rm -f $(SERVICE) $(CSS)
|
|
||||||
|
docker-image:
|
||||||
|
docker build -t cyp .
|
||||||
|
|
||||||
|
docker-run:
|
||||||
|
docker run --network=host -v "$$(pwd)"/_youtube:/cyp/_youtube cyp
|
||||||
|
|
||||||
.PHONY: all watch icons service clean
|
.PHONY: all watch icons service clean
|
||||||
|
|
56
README.md
|
@ -18,7 +18,7 @@ CYP is a web-based frontend for [MPD](https://www.musicpd.org/), the Music Playe
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Make sure you have a working MPD setup first.
|
Make sure you have a working MPD setup first and Node version >= 10
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/ondras/cyp.git && cd cyp
|
git clone https://github.com/ondras/cyp.git && cd cyp
|
||||||
|
@ -26,22 +26,56 @@ npm i
|
||||||
node .
|
node .
|
||||||
```
|
```
|
||||||
|
|
||||||
Point your browser to http://localhost:8080 to open the interface.
|
Point your browser to http://localhost:8080 to open the interface. Specify a custom MPD address via a `server` querystring argument (`?server=localhost:6655`).
|
||||||
|
|
||||||
|
## Instalation - Docker
|
||||||
|
|
||||||
|
Alternatively, you can use Docker to run CYP.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/ondras/cyp.git && cd cyp
|
||||||
|
docker build -t cyp .
|
||||||
|
docker run --network=host cyp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Youtube-dl integration
|
||||||
|
|
||||||
|
You will need a working [youtube-dl](https://ytdl-org.github.io/youtube-dl/index.html) installation. Audio files are downloaded into the `_youtube` directory, so make sure it is available to your MPD library (use a symlink).
|
||||||
|
|
||||||
|
If you use Docker, you need to mount the `_youtube` directory into the image:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --network=host -v "$(pwd)"/_youtube:/cyp/_youtube cyp
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Changing the port
|
||||||
|
|
||||||
|
...is done via the `PORT` environment variable. If you use Docker, the `-e` switch does the trick:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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
|
## Technology
|
||||||
- Connected to MPD via WebSockets (using the [ws2mpd](https://github.com/ondras/ws2mpd/) bridge)
|
- Connected to MPD via WebSockets (using the [ws2mpd](https://github.com/ondras/ws2mpd/) bridge)
|
||||||
- Token-based access to the WebSocket endpoint (better than an `Origin` check)
|
- Token-based access to the WebSocket endpoint (better than an `Origin` check)
|
||||||
- Modern ES6+ (modules, async/await)
|
- Written using [*Custom Elements*](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements)
|
||||||
- Responsive layout via Flexbox
|
- Responsive layout via Flexbox
|
||||||
- CSS Custom Properties
|
- CSS Custom Properties
|
||||||
- SVG icons (Material Design)
|
- SVG icons (Material Design)
|
||||||
- Can spawn Youtube-dl to download audio files
|
- Can spawn Youtube-dl to search/download audio files
|
||||||
- Album art retrieved directly from MPD (and cached via localStorage)
|
- Album art retrieved directly from MPD (and cached via localStorage)
|
||||||
|
|
||||||
|
|
||||||
## TODO
|
|
||||||
|
|
||||||
- [ ] Bundling
|
|
||||||
- [ ] Range styling
|
|
||||||
- [ ] Browser testing
|
|
||||||
|
|
0
_youtube/.empty
Normal file
844
app/app.css
|
@ -1,844 +0,0 @@
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: inherit;
|
|
||||||
}
|
|
||||||
html {
|
|
||||||
background-color: var(--fg);
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family: lato, sans-serif;
|
|
||||||
line-height: 1.25;
|
|
||||||
background-color: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
text-shadow: var(--text-shadow);
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
overflow: hidden;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
header,
|
|
||||||
footer {
|
|
||||||
z-index: 1;
|
|
||||||
box-shadow: var(--box-shadow);
|
|
||||||
}
|
|
||||||
input,
|
|
||||||
select,
|
|
||||||
button {
|
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
display: inline-flex;
|
|
||||||
white-space: nowrap;
|
|
||||||
background-color: transparent;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid var(--fg);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 2px 4px;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Lato';
|
|
||||||
src: url('font/LatoLatin-Regular.woff2') format('woff2');
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Lato';
|
|
||||||
src: url('font/LatoLatin-bold.woff2') format('woff2');
|
|
||||||
font-style: bold;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
.icon {
|
|
||||||
width: 24px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.icon path:not([fill]),
|
|
||||||
.icon polygon:not([fill]),
|
|
||||||
.icon circle:not([fill]) {
|
|
||||||
fill: currentColor;
|
|
||||||
}
|
|
||||||
.flex-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.flex-column {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.long-line {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.multiline {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.multiline h2 {
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
x-range {
|
|
||||||
--thumb-size: 8px;
|
|
||||||
--thumb-color: #fff;
|
|
||||||
--thumb-shadow: #000;
|
|
||||||
--track-size: 4px;
|
|
||||||
--track-color: gray;
|
|
||||||
--elapsed-color: lightgray;
|
|
||||||
--remaining-color: transparent;
|
|
||||||
--radius: calc(var(--track-size)/2);
|
|
||||||
width: 192px;
|
|
||||||
height: 16px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
x-range .-track,
|
|
||||||
x-range .-elapsed,
|
|
||||||
x-range .-remaining {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(50% - var(--track-size)/2);
|
|
||||||
height: var(--track-size);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
x-range .-track {
|
|
||||||
width: 100%;
|
|
||||||
left: 0;
|
|
||||||
background-color: gray;
|
|
||||||
}
|
|
||||||
x-range .-elapsed {
|
|
||||||
left: 0;
|
|
||||||
background-color: var(--elapsed-color);
|
|
||||||
}
|
|
||||||
x-range .-remaining {
|
|
||||||
right: 0;
|
|
||||||
background-color: var(--remaining-color);
|
|
||||||
}
|
|
||||||
x-range .-inner {
|
|
||||||
position: absolute;
|
|
||||||
left: var(--thumb-size);
|
|
||||||
right: var(--thumb-size);
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
x-range .-thumb {
|
|
||||||
all: unset;
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
border-radius: 50%;
|
|
||||||
width: calc(2*var(--thumb-size));
|
|
||||||
height: calc(2*var(--thumb-size));
|
|
||||||
background-color: var(--thumb-color);
|
|
||||||
box-shadow: 0 0 2px var(--thumb-shadow);
|
|
||||||
}
|
|
||||||
x-range[disabled] {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
main {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
nav ul {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
nav ul li {
|
|
||||||
flex: 1 0 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 0 8px 0;
|
|
||||||
border-top: 4px solid transparent;
|
|
||||||
}
|
|
||||||
nav ul li .icon {
|
|
||||||
margin-right: var(--icon-spacing);
|
|
||||||
}
|
|
||||||
nav ul li.active {
|
|
||||||
border-top-color: var(--primary);
|
|
||||||
color: var(--primary);
|
|
||||||
background-color: rgb(var(--primary-raw), 0.1);
|
|
||||||
}
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
nav ul li {
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
nav ul li:not([data-for=queue]) .icon {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
nav ul li span:not([id]) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#player {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
#player:not([data-state=play]) .pause {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
#player[data-state=play] .play {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
#player:not([data-flags~=random]) .random,
|
|
||||||
#player:not([data-flags~=repeat]) .repeat {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
#player x-range {
|
|
||||||
flex-grow: 1;
|
|
||||||
--elapsed-color: var(--primary);
|
|
||||||
}
|
|
||||||
#player .art {
|
|
||||||
margin-right: 0;
|
|
||||||
height: 96px;
|
|
||||||
}
|
|
||||||
#player .art img,
|
|
||||||
#player .art .icon {
|
|
||||||
width: 96px;
|
|
||||||
}
|
|
||||||
#player .info {
|
|
||||||
flex-grow: 2;
|
|
||||||
flex-basis: 0;
|
|
||||||
padding: 0 var(--icon-spacing);
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-around;
|
|
||||||
}
|
|
||||||
#player .info h2 {
|
|
||||||
font-size: 125%;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
#player .info .title,
|
|
||||||
#player .info .subtitle {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
#player .timeline {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
#player .timeline .duration,
|
|
||||||
#player .timeline .elapsed {
|
|
||||||
flex-basis: 5ch;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
#player .controls {
|
|
||||||
flex-grow: 1;
|
|
||||||
flex-basis: 0;
|
|
||||||
max-width: 220px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-around;
|
|
||||||
}
|
|
||||||
#player .controls .playback {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-around;
|
|
||||||
}
|
|
||||||
#player .controls .playback .icon {
|
|
||||||
width: 40px;
|
|
||||||
}
|
|
||||||
#player .controls .playback .icon-play,
|
|
||||||
#player .controls .playback .icon-pause {
|
|
||||||
width: 64px;
|
|
||||||
}
|
|
||||||
#player .controls .volume {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
#player .controls .volume .mute {
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
#player .misc {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-self: stretch;
|
|
||||||
justify-content: space-around;
|
|
||||||
}
|
|
||||||
#player .misc .icon {
|
|
||||||
width: 32px;
|
|
||||||
}
|
|
||||||
@media (max-width: 519px) {
|
|
||||||
#player {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
#player .info {
|
|
||||||
order: 1;
|
|
||||||
flex-basis: 100%;
|
|
||||||
height: 96px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.component {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.component header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--spacing);
|
|
||||||
}
|
|
||||||
.component header button {
|
|
||||||
font-size: var(--font-size-large);
|
|
||||||
font-weight: bold;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.component header button .icon {
|
|
||||||
margin-right: var(--icon-spacing);
|
|
||||||
}
|
|
||||||
.component ul {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: auto;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.component li {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.component li .info {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.component li .info .icon {
|
|
||||||
color: var(--primary);
|
|
||||||
margin-right: var(--icon-spacing);
|
|
||||||
filter: drop-shadow(var(--text-shadow));
|
|
||||||
}
|
|
||||||
.component li .info h2 {
|
|
||||||
font-size: var(--font-size-large);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.component li .info h2,
|
|
||||||
.component li .info div {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.component li:not(.has-art) {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
.component li button .icon {
|
|
||||||
width: 32px;
|
|
||||||
}
|
|
||||||
.component li:nth-child(odd) {
|
|
||||||
background-color: var(--bg-alt);
|
|
||||||
}
|
|
||||||
#queue {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
#queue header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--spacing);
|
|
||||||
}
|
|
||||||
#queue header button {
|
|
||||||
font-size: var(--font-size-large);
|
|
||||||
font-weight: bold;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
#queue header button .icon {
|
|
||||||
margin-right: var(--icon-spacing);
|
|
||||||
}
|
|
||||||
#queue ul {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: auto;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
#queue li {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
#queue li .info {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
#queue li .info .icon {
|
|
||||||
color: var(--primary);
|
|
||||||
margin-right: var(--icon-spacing);
|
|
||||||
filter: drop-shadow(var(--text-shadow));
|
|
||||||
}
|
|
||||||
#queue li .info h2 {
|
|
||||||
font-size: var(--font-size-large);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
#queue li .info h2,
|
|
||||||
#queue li .info div {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
#queue li:not(.has-art) {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
#queue li button .icon {
|
|
||||||
width: 32px;
|
|
||||||
}
|
|
||||||
#queue li:nth-child(odd) {
|
|
||||||
background-color: var(--bg-alt);
|
|
||||||
}
|
|
||||||
#queue .current {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
#library {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
#library header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--spacing);
|
|
||||||
}
|
|
||||||
#library header button {
|
|
||||||
font-size: var(--font-size-large);
|
|
||||||
font-weight: bold;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
#library header button .icon {
|
|
||||||
margin-right: var(--icon-spacing);
|
|
||||||
}
|
|
||||||
#library ul {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: auto;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
#library li {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
#library li .info {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
#library li .info .icon {
|
|
||||||
color: var(--primary);
|
|
||||||
margin-right: var(--icon-spacing);
|
|
||||||
filter: drop-shadow(var(--text-shadow));
|
|
||||||
}
|
|
||||||
#library li .info h2 {
|
|
||||||
font-size: var(--font-size-large);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
#library li .info h2,
|
|
||||||
#library li .info div {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
#library li:not(.has-art) {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
#library li button .icon {
|
|
||||||
width: 32px;
|
|
||||||
}
|
|
||||||
#library li:nth-child(odd) {
|
|
||||||
background-color: var(--bg-alt);
|
|
||||||
}
|
|
||||||
#library header {
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
#library .search {
|
|
||||||
order: 1;
|
|
||||||
}
|
|
||||||
#library .search.open ~ * {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
#library .art img,
|
|
||||||
#library .art .icon {
|
|
||||||
width: 64px;
|
|
||||||
}
|
|
||||||
#library .art .icon {
|
|
||||||
filter: drop-shadow(var(--text-shadow));
|
|
||||||
}
|
|
||||||
#library .group {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
#library .group h2 {
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
#library .tiles {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
grid-gap: 2px;
|
|
||||||
}
|
|
||||||
#library .tiles li {
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: rgba(255, 255, 255, 0.08);
|
|
||||||
height: 200px;
|
|
||||||
}
|
|
||||||
#library .tiles li h2 {
|
|
||||||
font-size: 150%;
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
#fs {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
#fs header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--spacing);
|
|
||||||
}
|
|
||||||
#fs header button {
|
|
||||||
font-size: var(--font-size-large);
|
|
||||||
font-weight: bold;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
#fs header button .icon {
|
|
||||||
margin-right: var(--icon-spacing);
|
|
||||||
}
|
|
||||||
#fs ul {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: auto;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
#fs li {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
#fs li .info {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
#fs li .info .icon {
|
|
||||||
color: var(--primary);
|
|
||||||
margin-right: var(--icon-spacing);
|
|
||||||
filter: drop-shadow(var(--text-shadow));
|
|
||||||
}
|
|
||||||
#fs li .info h2 {
|
|
||||||
font-size: var(--font-size-large);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
#fs li .info h2,
|
|
||||||
#fs li .info div {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
#fs li:not(.has-art) {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
#fs li button .icon {
|
|
||||||
width: 32px;
|
|
||||||
}
|
|
||||||
#fs li:nth-child(odd) {
|
|
||||||
background-color: var(--bg-alt);
|
|
||||||
}
|
|
||||||
#fs header {
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
#fs .search {
|
|
||||||
order: 1;
|
|
||||||
}
|
|
||||||
#fs .search.open ~ * {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
#fs .group {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
#fs .info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
#fs .info h2 {
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
#playlists {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
#playlists header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--spacing);
|
|
||||||
}
|
|
||||||
#playlists header button {
|
|
||||||
font-size: var(--font-size-large);
|
|
||||||
font-weight: bold;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
#playlists header button .icon {
|
|
||||||
margin-right: var(--icon-spacing);
|
|
||||||
}
|
|
||||||
#playlists ul {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: auto;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
#playlists li {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
#playlists li .info {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
#playlists li .info .icon {
|
|
||||||
color: var(--primary);
|
|
||||||
margin-right: var(--icon-spacing);
|
|
||||||
filter: drop-shadow(var(--text-shadow));
|
|
||||||
}
|
|
||||||
#playlists li .info h2 {
|
|
||||||
font-size: var(--font-size-large);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
#playlists li .info h2,
|
|
||||||
#playlists li .info div {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
#playlists li:not(.has-art) {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
#playlists li button .icon {
|
|
||||||
width: 32px;
|
|
||||||
}
|
|
||||||
#playlists li:nth-child(odd) {
|
|
||||||
background-color: var(--bg-alt);
|
|
||||||
}
|
|
||||||
#playlists .info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
#playlists .info h2 {
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
#yt {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
#yt header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--spacing);
|
|
||||||
}
|
|
||||||
#yt header button {
|
|
||||||
font-size: var(--font-size-large);
|
|
||||||
font-weight: bold;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
#yt header button .icon {
|
|
||||||
margin-right: var(--icon-spacing);
|
|
||||||
}
|
|
||||||
#yt ul {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: auto;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
#yt li {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
#yt li .info {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
#yt li .info .icon {
|
|
||||||
color: var(--primary);
|
|
||||||
margin-right: var(--icon-spacing);
|
|
||||||
filter: drop-shadow(var(--text-shadow));
|
|
||||||
}
|
|
||||||
#yt li .info h2 {
|
|
||||||
font-size: var(--font-size-large);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
#yt li .info h2,
|
|
||||||
#yt li .info div {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
#yt li:not(.has-art) {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
#yt li button .icon {
|
|
||||||
width: 32px;
|
|
||||||
}
|
|
||||||
#yt li:nth-child(odd) {
|
|
||||||
background-color: var(--bg-alt);
|
|
||||||
}
|
|
||||||
#yt header {
|
|
||||||
border-bottom: 1px solid var(--fg);
|
|
||||||
}
|
|
||||||
#yt header button + button {
|
|
||||||
margin-left: 16px;
|
|
||||||
}
|
|
||||||
#yt .clear {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
#yt pre {
|
|
||||||
margin: 0.5em 0.5ch;
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
#yt.pending header {
|
|
||||||
background-image: linear-gradient(var(--primary), var(--primary));
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: 25% 4px;
|
|
||||||
animation: bar ease-in-out 3s alternate infinite;
|
|
||||||
}
|
|
||||||
@keyframes bar {
|
|
||||||
0% {
|
|
||||||
background-position: 0 100%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 100% 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#settings {
|
|
||||||
font-size: var(--font-size-large);
|
|
||||||
}
|
|
||||||
#settings dl {
|
|
||||||
margin: var(--spacing);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: max-content 1fr;
|
|
||||||
align-items: center;
|
|
||||||
grid-gap: var(--spacing);
|
|
||||||
}
|
|
||||||
#settings dt {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
#settings dd {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
#settings label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
#settings label [type=radio],
|
|
||||||
#settings label [type=checkbox] {
|
|
||||||
margin: 0 4px 0 0;
|
|
||||||
}
|
|
||||||
.search {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
margin-left: auto;
|
|
||||||
transition: all 300ms;
|
|
||||||
width: 32px;
|
|
||||||
max-width: 20ch;
|
|
||||||
}
|
|
||||||
.search .icon {
|
|
||||||
width: 32px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.search input {
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
color: inherit;
|
|
||||||
background-color: inherit;
|
|
||||||
border-bottom: 1px solid var(--fg);
|
|
||||||
width: 0;
|
|
||||||
padding: 0;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
.search.open {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.art {
|
|
||||||
margin-right: var(--icon-spacing);
|
|
||||||
}
|
|
||||||
.art .icon,
|
|
||||||
.art img {
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
:root {
|
|
||||||
--font-size-large: 112.5%;
|
|
||||||
--icon-spacing: 4px;
|
|
||||||
--primary: rgb(var(--primary-raw));
|
|
||||||
--spacing: 8px;
|
|
||||||
--box-shadow: 0 0 3px #000;
|
|
||||||
}
|
|
||||||
:root[data-theme=light] {
|
|
||||||
--fg: #333;
|
|
||||||
--bg: #f0f0f0;
|
|
||||||
--bg-alt: #e0e0e0;
|
|
||||||
--text-shadow: none;
|
|
||||||
}
|
|
||||||
:root[data-theme=dark] {
|
|
||||||
--fg: #f0f0f0;
|
|
||||||
--bg: #333;
|
|
||||||
--bg-alt: #555;
|
|
||||||
--text-shadow: 0 1px 1px rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
|
||||||
:root[data-color=dodgerblue] {
|
|
||||||
--primary-raw: 30, 144, 255;
|
|
||||||
}
|
|
||||||
:root[data-color=darkorange] {
|
|
||||||
--primary-raw: 255, 140, 0;
|
|
||||||
}
|
|
||||||
:root[data-color=limegreen] {
|
|
||||||
--primary-raw: 50, 205, 50;
|
|
||||||
}
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
:root {
|
|
||||||
--spacing: var(--icon-spacing);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,71 +0,0 @@
|
||||||
*, *::before, *::after { box-sizing: inherit; }
|
|
||||||
|
|
||||||
html {
|
|
||||||
background-color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
.flex-column;
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family: lato, sans-serif;
|
|
||||||
line-height: 1.25;
|
|
||||||
background-color: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
text-shadow: var(--text-shadow);
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
overflow: hidden;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
header, footer {
|
|
||||||
z-index: 1;
|
|
||||||
box-shadow: var(--box-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
input, select, button {
|
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
|
|
||||||
.flex-row;
|
|
||||||
display: inline-flex;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
background-color: transparent;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid var(--fg);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 2px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@import "font.less";
|
|
||||||
@import "icons.less";
|
|
||||||
@import "mixins.less";
|
|
||||||
@import "range.less";
|
|
||||||
@import "main.less";
|
|
||||||
@import "nav.less";
|
|
||||||
@import "player.less";
|
|
||||||
@import "component.less";
|
|
||||||
@import "queue.less";
|
|
||||||
@import "library.less";
|
|
||||||
@import "fs.less";
|
|
||||||
@import "playlists.less";
|
|
||||||
@import "yt.less";
|
|
||||||
@import "settings.less";
|
|
||||||
@import "search.less";
|
|
||||||
@import "art.less";
|
|
||||||
@import "variables.less";
|
|
|
@ -1,6 +1,7 @@
|
||||||
.art {
|
.art {
|
||||||
margin-right: var(--icon-spacing);
|
flex: none;
|
||||||
.icon, img {
|
.icon, img {
|
||||||
vertical-align: top;
|
display: block;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,61 +0,0 @@
|
||||||
.component {
|
|
||||||
height: 100%;
|
|
||||||
.flex-column;
|
|
||||||
|
|
||||||
header {
|
|
||||||
.flex-row;
|
|
||||||
padding: var(--spacing);
|
|
||||||
|
|
||||||
button {
|
|
||||||
font-size: var(--font-size-large);
|
|
||||||
font-weight: bold;
|
|
||||||
.icon { margin-right: var(--icon-spacing); }
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: auto;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
.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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
&.has-art {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.has-art) {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button .icon { width: 32px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
li:nth-child(odd) {
|
|
||||||
background-color: var(--bg-alt);
|
|
||||||
}
|
|
||||||
}
|
|
92
app/css/cyp.less
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
*, *::before, *::after { box-sizing: inherit; }
|
||||||
|
|
||||||
|
html {
|
||||||
|
background-color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: auto;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
header, footer {
|
||||||
|
flex: none;
|
||||||
|
z-index: 1;
|
||||||
|
box-shadow: var(--box-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
position: relative; // kotva pro cyp-commands
|
||||||
|
overflow: hidden; // vyjizdenici cyp-commands
|
||||||
|
height: 56px;
|
||||||
|
@media (max-width: @breakpoint-menu) {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
option {
|
||||||
|
color: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
|
||||||
|
&:not([hidden]) { display: flex; }
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
flex: none;
|
||||||
|
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--fg);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@import "font.less";
|
||||||
|
@import "icons.less";
|
||||||
|
@import "mixins.less";
|
||||||
|
@import "art.less";
|
||||||
|
@import "variables.less";
|
||||||
|
|
||||||
|
@import "elements/app.less";
|
||||||
|
@import "elements/menu.less";
|
||||||
|
@import "elements/song.less";
|
||||||
|
@import "elements/player.less";
|
||||||
|
@import "elements/playlists.less";
|
||||||
|
@import "elements/queue.less";
|
||||||
|
@import "elements/settings.less";
|
||||||
|
@import "elements/yt.less";
|
||||||
|
@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";
|
||||||
|
@import "elements/path.less";
|
||||||
|
@import "elements/yt-result.less";
|
16
app/css/elements/app.less
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
cyp-app {
|
||||||
|
.flex-column;
|
||||||
|
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 800px;
|
||||||
|
height: calc(100px * var(--vh));
|
||||||
|
|
||||||
|
font-family: lato, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.25;
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
3
app/css/elements/back.less
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
cyp-back {
|
||||||
|
.item;
|
||||||
|
}
|
8
app/css/elements/filter.less
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
cyp-filter {
|
||||||
|
.item;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 32px;
|
||||||
|
margin-left: var(--icon-spacing);
|
||||||
|
}
|
||||||
|
}
|
18
app/css/elements/library.less
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
58
app/css/elements/menu.less
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
cyp-menu, cyp-commands {
|
||||||
|
.flex-row;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
button {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.flex-column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
@media (max-width: @breakpoint-menu) {
|
||||||
|
flex-direction: row;
|
||||||
|
span:not([id]) { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon + * { margin-top: 2px; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cyp-menu button {
|
||||||
|
flex: 1 0 0;
|
||||||
|
border-top: var(--border-width) solid transparent;
|
||||||
|
border-bottom: var(--border-width) solid transparent;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-right: var(--icon-spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
background-color: var(--primary-tint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 1 @breakpoint-menu/6;
|
||||||
|
&.last {
|
||||||
|
order: 1;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
3
app/css/elements/path.less
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
cyp-path {
|
||||||
|
.item;
|
||||||
|
}
|
|
@ -1,80 +1,86 @@
|
||||||
#player {
|
cyp-player {
|
||||||
@art-size: 96px;
|
@art-size: 96px;
|
||||||
|
|
||||||
.flex-row;
|
.flex-row;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|
||||||
&:not([data-state=play]) .pause { display: none; }
|
&:not([data-state=play]) .pause { display: none; }
|
||||||
&[data-state=play] .play { display: none; }
|
&[data-state=play] .play { display: none; }
|
||||||
&:not([data-flags~=random]) .random, &:not([data-flags~=repeat]) .repeat { opacity: 0.5; }
|
&:not([data-flags~=random]) .random, &:not([data-flags~=repeat]) .repeat { opacity: 0.5; }
|
||||||
|
&[data-flags~=mute] .mute .icon-volume-high { display: none; }
|
||||||
|
&:not([data-flags~=mute]) .mute .icon-volume-off { display: none; }
|
||||||
|
|
||||||
x-range {
|
x-range {
|
||||||
flex-grow: 1;
|
flex: auto;
|
||||||
--elapsed-color: var(--primary);
|
--elapsed-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.art {
|
.art {
|
||||||
margin-right: 0;
|
|
||||||
height: @art-size;
|
|
||||||
img, .icon {
|
|
||||||
width: @art-size;
|
width: @art-size;
|
||||||
}
|
height: @art-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
flex-grow: 2;
|
flex: auto;
|
||||||
flex-basis: 0;
|
min-width: 0;
|
||||||
padding: 0 var(--icon-spacing);
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.flex-column;
|
.flex-column;
|
||||||
justify-content: space-around;
|
justify-content: space-between;
|
||||||
|
|
||||||
h2 {
|
padding: 0 var(--icon-spacing);
|
||||||
font-size: 125%;
|
|
||||||
margin: 0;
|
.title {
|
||||||
|
margin-top: 8px;
|
||||||
|
.font-large;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
.title, .subtitle { .ellipsis; }
|
||||||
.title, .subtitle { .long-line; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
|
flex: none;
|
||||||
|
height: var(--icon-size);
|
||||||
|
margin-bottom: 4px;
|
||||||
.flex-row;
|
.flex-row;
|
||||||
|
|
||||||
.duration, .elapsed {
|
.duration, .elapsed {
|
||||||
flex-basis: 5ch;
|
flex: none;
|
||||||
|
width: 5ch;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
flex-grow: 1;
|
width: 220px;
|
||||||
flex-basis: 0;
|
min-width: 0;
|
||||||
max-width: 220px;
|
|
||||||
|
|
||||||
.flex-column;
|
.flex-column;
|
||||||
justify-content: space-around;
|
|
||||||
|
|
||||||
.playback {
|
.playback {
|
||||||
|
flex: auto;
|
||||||
|
|
||||||
.flex-row;
|
.flex-row;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
|
|
||||||
.icon { width: 40px; }
|
.icon { width: 40px; }
|
||||||
|
|
||||||
.icon-play, .icon-pause { width: 64px; }
|
.icon-play, .icon-pause { width: 64px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume {
|
.volume {
|
||||||
.flex-row;
|
flex: none;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
.mute { margin-right: 4px; }
|
.flex-row;
|
||||||
|
.mute { margin-right: var(--icon-spacing); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.misc {
|
.misc {
|
||||||
display: flex;
|
flex: none;
|
||||||
flex-direction: column;
|
|
||||||
align-self: stretch;
|
.flex-column;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
|
|
||||||
.icon { width: 32px; }
|
.icon { width: 32px; }
|
||||||
}
|
}
|
||||||
|
|
3
app/css/elements/playlist.less
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
cyp-playlist {
|
||||||
|
.item;
|
||||||
|
}
|
2
app/css/elements/playlists.less
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
cyp-playlists {
|
||||||
|
}
|
5
app/css/elements/queue.less
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
cyp-queue {
|
||||||
|
.current {
|
||||||
|
> .icon { color: var(--primary); }
|
||||||
|
}
|
||||||
|
}
|
1
app/css/elements/range.less
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
../../../node_modules/custom-range/range.less
|
23
app/css/elements/search.less
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
cyp-search {
|
||||||
|
form {
|
||||||
|
.item;
|
||||||
|
align-items: stretch;
|
||||||
|
|
||||||
|
button:first-of-type { // pseudo-class to override
|
||||||
|
margin-left: var(--icon-spacing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.pending form {
|
||||||
|
background-image: linear-gradient(var(--primary), var(--primary));
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 25% var(--border-width);
|
||||||
|
animation: bar ease-in-out 3s alternate infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes bar {
|
||||||
|
0% { background-position: 0 100%; }
|
||||||
|
100% { background-position: 100% 100%; }
|
||||||
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
#settings {
|
cyp-settings {
|
||||||
font-size: var(--font-size-large);
|
--spacing: 8px;
|
||||||
|
|
||||||
|
.font-large;
|
||||||
|
|
||||||
dl {
|
dl {
|
||||||
margin: var(--spacing);
|
margin: var(--spacing);
|
46
app/css/elements/song.less
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
cyp-song {
|
||||||
|
.item;
|
||||||
|
|
||||||
|
.multiline {
|
||||||
|
.flex-column;
|
||||||
|
min-width: 0; // bez tohoto se odmita zmensit
|
||||||
|
|
||||||
|
.subtitle { .ellipsis; }
|
||||||
|
}
|
||||||
|
|
||||||
|
cyp-queue & {
|
||||||
|
> .icon {
|
||||||
|
width: 32px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.track { display: none; }
|
||||||
|
|
||||||
|
&:not(.playing) > .icon-play, &.playing > .icon-music {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.playing {
|
||||||
|
> .icon { color: var(--primary) }
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--primary);
|
||||||
|
width: calc(100% * var(--progress, 0));
|
||||||
|
height: var(--border-width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cyp-library & {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
|
||||||
|
> .icon { width: 64px;}
|
||||||
|
> .icon-play { display: none; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
13
app/css/elements/tag.less
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
cyp-tag {
|
||||||
|
.item;
|
||||||
|
|
||||||
|
padding-left: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
|
||||||
|
.art {
|
||||||
|
margin-right: var(--icon-spacing);
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
}
|
8
app/css/elements/yt-result.less
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
cyp-yt-result {
|
||||||
|
.item;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
button .icon {
|
||||||
|
width: var(--icon-size);
|
||||||
|
}
|
||||||
|
}
|
8
app/css/elements/yt.less
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
cyp-yt {
|
||||||
|
pre {
|
||||||
|
margin: 0.5em 0.5ch;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,13 +1,13 @@
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Lato';
|
font-family: "Lato";
|
||||||
src: url('font/LatoLatin-Regular.woff2') format('woff2');
|
src: url("font/LatoLatin-Regular.woff2") format("woff2");
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Lato';
|
font-family: "Lato";
|
||||||
src: url('font/LatoLatin-bold.woff2') format('woff2');
|
src: url("font/LatoLatin-Bold.woff2") format("woff2");
|
||||||
font-style: bold;
|
font-style: normal;
|
||||||
font-weight: normal;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
#fs {
|
|
||||||
.component;
|
|
||||||
|
|
||||||
header {
|
|
||||||
white-space: pre; // separator
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
order: 1;
|
|
||||||
&.open ~ * { display: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.group {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
.multiline;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
.icon {
|
.icon {
|
||||||
width: 24px;
|
width: var(--icon-size);
|
||||||
flex-shrink: 0;
|
flex: none;
|
||||||
|
|
||||||
path, polygon, circle {
|
path, polygon, circle {
|
||||||
&:not([fill]) {
|
&:not([fill]) {
|
||||||
|
|
|
@ -1,43 +0,0 @@
|
||||||
#library {
|
|
||||||
.component;
|
|
||||||
|
|
||||||
header {
|
|
||||||
white-space: pre; // separator
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
order: 1;
|
|
||||||
&.open ~ * { display: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.art img, .art .icon {
|
|
||||||
width: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.art .icon {
|
|
||||||
filter: drop-shadow(var(--text-shadow));
|
|
||||||
}
|
|
||||||
|
|
||||||
.group {
|
|
||||||
cursor: pointer;
|
|
||||||
h2 { font-weight: normal; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiles {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
grid-gap: 2px;
|
|
||||||
|
|
||||||
li {
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: rgba(255, 255, 255, 0.08);
|
|
||||||
height: 200px;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 150%;
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +0,0 @@
|
||||||
main {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
|
@ -1,22 +1,69 @@
|
||||||
.flex-row {
|
.flex-row {
|
||||||
display: flex;
|
&:not([hidden]) { display: flex; }
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex-column {
|
.flex-column {
|
||||||
display: flex;
|
&:not([hidden]) { display: flex; }
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.long-line {
|
.ellipsis {
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.multiline {
|
.font-large {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectable {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative; // kotva pro selected::before
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
color: var(--primary);
|
||||||
|
background-color: var(--primary-tint);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: var(--border-width);
|
||||||
|
background-color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
.flex-row;
|
.flex-row;
|
||||||
|
|
||||||
h2 { font-weight: normal; }
|
&:nth-child(odd) {
|
||||||
|
background-color: var(--bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectable; // az po nth-child, aby byl vyber pozdeji
|
||||||
|
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
> .icon {
|
||||||
|
margin-right: var(--icon-spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
.font-large;
|
||||||
|
.ellipsis;
|
||||||
|
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
&:first-of-type { margin-left: auto; }
|
||||||
|
.icon { width: 32px; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,36 +0,0 @@
|
||||||
nav ul {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
.flex-row;
|
|
||||||
|
|
||||||
li {
|
|
||||||
flex: 1 0 0;
|
|
||||||
|
|
||||||
.flex-column;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 0 8px 0;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:not([data-for=queue]) .icon { margin-right: 0; }
|
|
||||||
span:not([id]) { display: none; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
#playlists {
|
|
||||||
.component;
|
|
||||||
|
|
||||||
.info {
|
|
||||||
.multiline;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
#queue {
|
|
||||||
.component;
|
|
||||||
|
|
||||||
.current { color: var(--primary); }
|
|
||||||
}
|
|
|
@ -1,63 +0,0 @@
|
||||||
x-range {
|
|
||||||
--thumb-size: 8px;
|
|
||||||
--thumb-color: #fff;
|
|
||||||
--thumb-shadow: #000;
|
|
||||||
--track-size: 4px;
|
|
||||||
--track-color: gray;
|
|
||||||
--elapsed-color: lightgray;
|
|
||||||
--remaining-color: transparent;
|
|
||||||
|
|
||||||
--radius: calc(var(--track-size)/2);
|
|
||||||
|
|
||||||
width: 192px;
|
|
||||||
height: 16px;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.-track, .-elapsed, .-remaining {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(50% - var(--track-size)/2);
|
|
||||||
height: var(--track-size);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.-track {
|
|
||||||
width: 100%;
|
|
||||||
left: 0;
|
|
||||||
background-color: gray;
|
|
||||||
}
|
|
||||||
|
|
||||||
.-elapsed {
|
|
||||||
left: 0;
|
|
||||||
background-color: var(--elapsed-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.-remaining {
|
|
||||||
right: 0;
|
|
||||||
background-color: var(--remaining-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.-inner {
|
|
||||||
position: absolute;
|
|
||||||
left: var(--thumb-size);
|
|
||||||
right: var(--thumb-size);
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.-thumb {
|
|
||||||
all: unset;
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
|
|
||||||
border-radius: 50%;
|
|
||||||
width: calc(2*var(--thumb-size));
|
|
||||||
height: calc(2*var(--thumb-size));
|
|
||||||
background-color: var(--thumb-color);
|
|
||||||
box-shadow: 0 0 2px var(--thumb-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[disabled] {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,27 +0,0 @@
|
||||||
.search {
|
|
||||||
.flex-row;
|
|
||||||
margin-left: auto;
|
|
||||||
transition: all 300ms;
|
|
||||||
width: 32px;
|
|
||||||
max-width: 20ch;
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 32px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
color: inherit;
|
|
||||||
background-color: inherit;
|
|
||||||
border-bottom: 1px solid var(--fg);
|
|
||||||
width: 0;
|
|
||||||
padding: 0;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.open {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,39 +1,53 @@
|
||||||
:root {
|
@breakpoint-menu: 480px;
|
||||||
--font-size-large: 112.5%;
|
|
||||||
|
cyp-app {
|
||||||
|
--icon-size: 24px;
|
||||||
--icon-spacing: 4px;
|
--icon-spacing: 4px;
|
||||||
--primary: rgb(var(--primary-raw));
|
--primary: rgb(var(--primary-raw));
|
||||||
--spacing: 8px;
|
--primary-tint: rgba(var(--primary-raw), 0.1);
|
||||||
--box-shadow: 0 0 3px #000;
|
--box-shadow: 0 0 3px #000;
|
||||||
|
--border-width: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme=light] {
|
.light() {
|
||||||
--fg: #333;
|
--fg: #333;
|
||||||
--bg: #f0f0f0;
|
--bg: #f0f0f0;
|
||||||
--bg-alt: #e0e0e0;
|
--bg-alt: #e0e0e0;
|
||||||
--text-shadow: none;
|
--text-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme=dark] {
|
.dark() {
|
||||||
--fg: #f0f0f0;
|
--fg: #f0f0f0;
|
||||||
--bg: #333;
|
--bg: #333;
|
||||||
--bg-alt: #555;
|
--bg-alt: #444;
|
||||||
--text-shadow: 0 1px 1px rgba(0, 0, 0, 0.8);
|
--text-shadow: 0 1px 1px rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-color=dodgerblue] {
|
cyp-app[theme=light] { .light(); }
|
||||||
|
cyp-app[theme=dark] { .dark(); }
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
cyp-app[theme=auto] { .dark(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
cyp-app[theme=auto] { .light(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px), (max-height:640px) {
|
||||||
|
cyp-app[theme] {
|
||||||
|
--text-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cyp-app[color=dodgerblue] {
|
||||||
--primary-raw: 30, 144, 255;
|
--primary-raw: 30, 144, 255;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-color=darkorange] {
|
cyp-app[color=darkorange] {
|
||||||
--primary-raw: 255, 140, 0;
|
--primary-raw: 255, 140, 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-color=limegreen] {
|
cyp-app[color=limegreen] {
|
||||||
--primary-raw: 50, 205, 50;
|
--primary-raw: 50, 205, 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
:root {
|
|
||||||
--spacing: var(--icon-spacing);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,34 +0,0 @@
|
||||||
#yt {
|
|
||||||
.component;
|
|
||||||
|
|
||||||
header {
|
|
||||||
border-bottom: 1px solid var(--fg);
|
|
||||||
|
|
||||||
button + button {
|
|
||||||
margin-left: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
margin: 0.5em 0.5ch;
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.pending header {
|
|
||||||
background-image: linear-gradient(var(--primary), var(--primary));
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: 25% 4px;
|
|
||||||
animation: bar ease-in-out 3s alternate infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bar {
|
|
||||||
0% { background-position: 0 100%; }
|
|
||||||
100% { background-position: 100% 100%; }
|
|
||||||
}
|
|
1
app/cyp.css
Normal file
2040
app/cyp.js
Normal file
|
@ -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="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>
|
|
Before Width: | Height: | Size: 585 B |
|
@ -1 +1 @@
|
||||||
<?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="M17,13H7V11H17M12,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>
|
<?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="M9,4H15V12H19.84L12,19.84L4.16,12H9V4Z" /></svg>
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 333 B |
|
@ -1 +1 @@
|
||||||
<?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="M10,16.5V7.5L16,12M12,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>
|
<?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="M15,20H9V12H4.16L12,4.16L19.84,12H15V20Z" /></svg>
|
Before Width: | Height: | Size: 389 B After Width: | Height: | Size: 335 B |
1
app/icons/cancel.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<?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="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>
|
After Width: | Height: | Size: 546 B |
1
app/icons/checkbox-marked-outline.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<?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="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>
|
After Width: | Height: | Size: 436 B |
1
app/icons/chevron-double-right.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<?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="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>
|
After Width: | Height: | Size: 411 B |
|
@ -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="M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2C6.47,2 2,6.47 2,12C2,17.53 6.47,22 12,22C17.53,22 22,17.53 22,12C22,6.47 17.53,2 12,2M14.59,8L12,10.59L9.41,8L8,9.41L10.59,12L8,14.59L9.41,16L12,13.41L14.59,16L16,14.59L13.41,12L16,9.41L14.59,8Z" /></svg>
|
|
Before Width: | Height: | Size: 587 B |
|
@ -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="M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z" /></svg>
|
|
Before Width: | Height: | Size: 495 B |
|
@ -1 +1 @@
|
||||||
<?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="M15,16H13V8H15M11,16H9V8H11M12,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>
|
<?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="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z" /></svg>
|
Before Width: | Height: | Size: 398 B After Width: | Height: | Size: 376 B |
|
@ -1 +1 @@
|
||||||
<?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="M17,13H13V17H11V13H7V11H11V7H13V11H17M12,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>
|
<?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: 408 B After Width: | Height: | Size: 338 B |
1
app/icons/keyboard-backspace.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<?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="M21,11H6.83L10.41,7.41L9,6L3,12L9,18L10.41,16.58L6.83,13H21V11Z" /></svg>
|
After Width: | Height: | Size: 358 B |
|
@ -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="M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M7,13H17V11H7" /></svg>
|
|
Before Width: | Height: | Size: 475 B |
Before Width: | Height: | Size: 313 B After Width: | Height: | Size: 313 B |
|
@ -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="M13,16V8H15V16H13M9,16V8H11V16H9M12,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,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z" /></svg>
|
|
Before Width: | Height: | Size: 470 B |
|
@ -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="M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M10,16.5L16,12L10,7.5V16.5Z" /></svg>
|
|
Before Width: | Height: | Size: 489 B |
|
@ -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="M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M13,7H11V11H7V13H11V17H13V13H17V11H13V7Z" /></svg>
|
|
Before Width: | Height: | Size: 502 B |
176
app/index.html
|
@ -4,18 +4,111 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<title>Control Your Player</title>
|
<title>Control Your Player</title>
|
||||||
<link rel="stylesheet" href="app.css" />
|
<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" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<header>
|
||||||
<section id="player">
|
<cyp-player>
|
||||||
<span class="art"></span>
|
<span class="art"></span>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<h2 class="title"></h2>
|
<span class="title"></span>
|
||||||
<div class="subtitle"></div>
|
<span class="subtitle"></span>
|
||||||
<div class="timeline">
|
<div class="timeline">
|
||||||
<span class="elapsed"></span>
|
<span class="elapsed"></span>
|
||||||
<x-range></x-range>
|
<x-range step="0.1"></x-range>
|
||||||
<span class="duration"></span>
|
<span class="duration"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -27,7 +120,7 @@
|
||||||
<button class="next" data-icon="fast-forward"></button>
|
<button class="next" data-icon="fast-forward"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="volume">
|
<div class="volume">
|
||||||
<button class="mute" data-icon="volume-high"></button>
|
<button class="mute" data-icon="volume-high volume-off"></button>
|
||||||
<x-range></x-range>
|
<x-range></x-range>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -35,40 +128,19 @@
|
||||||
<button class="repeat" data-icon="repeat"></button>
|
<button class="repeat" data-icon="repeat"></button>
|
||||||
<button class="random" data-icon="shuffle"></button>
|
<button class="random" data-icon="shuffle"></button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</cyp-player>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<section id="queue">
|
<cyp-queue></cyp-queue>
|
||||||
<header>
|
<cyp-playlists></cyp-playlists>
|
||||||
<button class="clear" data-icon="close" title="Clear the queue"></button>
|
<cyp-library></cyp-library>
|
||||||
<button class="save" data-icon="content-save" title="Save the queue"></button>
|
<cyp-yt></cyp-yt>
|
||||||
</header>
|
<cyp-settings>
|
||||||
<ul></ul>
|
|
||||||
</section>
|
|
||||||
<section id="playlists">
|
|
||||||
<ul></ul>
|
|
||||||
</section>
|
|
||||||
<section id="library">
|
|
||||||
<header></header>
|
|
||||||
<ul></ul>
|
|
||||||
</section>
|
|
||||||
<section id="fs">
|
|
||||||
<header></header>
|
|
||||||
<ul></ul>
|
|
||||||
</section>
|
|
||||||
<section id="yt">
|
|
||||||
<header>
|
|
||||||
<button class="download" data-icon="download">Download</button>
|
|
||||||
<button class="search-download" data-icon="magnify">Search & Download</button>
|
|
||||||
<button class="clear" data-icon="close">Clear</button>
|
|
||||||
</header>
|
|
||||||
<pre></pre>
|
|
||||||
</section>
|
|
||||||
<section id="settings">
|
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Theme</dt>
|
<dt>Theme</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<select name="theme">
|
<select name="theme">
|
||||||
|
<option value="auto">Auto</option>
|
||||||
<option value="dark">Dark</option>
|
<option value="dark">Dark</option>
|
||||||
<option value="light">Light</option>
|
<option value="light">Light</option>
|
||||||
</select>
|
</select>
|
||||||
|
@ -88,26 +160,34 @@
|
||||||
Green
|
Green
|
||||||
</label>
|
</label>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt>YouTube results</dt>
|
||||||
|
<dd>
|
||||||
|
<select name="yt-limit">
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="3">3</option>
|
||||||
|
<option value="5">5</option>
|
||||||
|
<option value="10">10</option>
|
||||||
|
</select>
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</cyp-settings>
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer>
|
||||||
<nav>
|
<cyp-menu>
|
||||||
<ul>
|
<button data-for="queue" data-icon="music">
|
||||||
<li data-for="queue" data-icon="music">
|
|
||||||
<div>
|
<div>
|
||||||
<span>Queue</span>
|
<span>Queue</span>
|
||||||
<span id="queue-length"></span>
|
<span class="queue-length"></span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</button>
|
||||||
<li data-for="playlists" data-icon="playlist-music"><span>Playlists</span></li>
|
<button data-for="playlists" data-icon="playlist-music"><span>Playlists</span></button>
|
||||||
<li data-for="library" data-icon="library-music"><span>Library</span></li>
|
<button data-for="library" data-icon="library-music"><span>Library</span></button>
|
||||||
<li data-for="fs" data-icon="folder"><span>Files</span></li>
|
<button data-for="yt" data-icon="download"><span>YouTube</span></button>
|
||||||
<li data-for="yt" data-icon="download"><span>YouTube</span></li>
|
<button data-for="settings" data-icon="settings"><span>Settings</span></button>
|
||||||
<li data-for="settings" data-icon="settings"><span>Settings</span></li>
|
</cyp-menu>
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</footer>
|
</footer>
|
||||||
<script type="module" src="js/app.js"></script>
|
</cyp-app>
|
||||||
|
|
||||||
|
<script type="module" src="cyp.js?2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -1,67 +0,0 @@
|
||||||
import * as nav from "./nav.js";
|
|
||||||
import * as mpd from "./lib/mpd.js";
|
|
||||||
import * as player from "./player.js";
|
|
||||||
import * as html from "./lib/html.js";
|
|
||||||
import * as range from "./lib/range.js";
|
|
||||||
|
|
||||||
import * as queue from "./queue.js";
|
|
||||||
import * as library from "./library.js";
|
|
||||||
import * as fs from "./fs.js";
|
|
||||||
import * as playlists from "./playlists.js";
|
|
||||||
import * as yt from "./yt.js";
|
|
||||||
import * as settings from "./settings.js";
|
|
||||||
|
|
||||||
const components = { queue, library, fs, playlists, yt, settings };
|
|
||||||
|
|
||||||
export function activate(what) {
|
|
||||||
location.hash = what;
|
|
||||||
|
|
||||||
for (let id in components) {
|
|
||||||
let node = document.querySelector(`#${id}`);
|
|
||||||
if (what == id) {
|
|
||||||
node.style.display = "";
|
|
||||||
components[id].activate();
|
|
||||||
} else {
|
|
||||||
node.style.display = "none";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nav.active(what);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initIcons() {
|
|
||||||
Array.from(document.querySelectorAll("[data-icon]")).forEach(node => {
|
|
||||||
let icon = html.icon(node.dataset.icon);
|
|
||||||
node.insertBefore(icon, node.firstChild);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromHash() {
|
|
||||||
let hash = location.hash.substring(1);
|
|
||||||
activate(hash || "queue");
|
|
||||||
}
|
|
||||||
|
|
||||||
function onHashChange(e) {
|
|
||||||
fromHash();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
initIcons();
|
|
||||||
try {
|
|
||||||
await mpd.init();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav.init(document.querySelector("nav"));
|
|
||||||
for (let id in components) {
|
|
||||||
let node = document.querySelector(`#${id}`);
|
|
||||||
components[id].init(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
player.init(document.querySelector("#player"));
|
|
||||||
window.addEventListener("hashchange", onHashChange);
|
|
||||||
fromHash();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
init();
|
|
65
app/js/art.js
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import * as html from "./html.js";
|
||||||
|
import * as conf from "./conf.js";
|
||||||
|
|
||||||
|
const cache = {};
|
||||||
|
const MIME = "image/jpeg";
|
||||||
|
const STORAGE_PREFIX = `art-${conf.artSize}` ;
|
||||||
|
|
||||||
|
function store(key, data) {
|
||||||
|
localStorage.setItem(`${STORAGE_PREFIX}-${key}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(key) {
|
||||||
|
return localStorage.getItem(`${STORAGE_PREFIX}-${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bytesToImage(bytes) {
|
||||||
|
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) {
|
||||||
|
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);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get(mpd, artist, album, songUrl = null) {
|
||||||
|
const key = `${artist}-${album}`;
|
||||||
|
if (key in cache) { return cache[key]; }
|
||||||
|
|
||||||
|
const loaded = await load(key);
|
||||||
|
if (loaded) {
|
||||||
|
cache[key] = loaded;
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!songUrl) { return null; }
|
||||||
|
|
||||||
|
// promise to be returned in the meantime
|
||||||
|
let resolve;
|
||||||
|
const promise = new Promise(res => resolve = res);
|
||||||
|
cache[key] = promise;
|
||||||
|
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
cache[key] = null;
|
||||||
|
}
|
||||||
|
return cache[key];
|
||||||
|
}
|
28
app/js/component.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import Selection from "./selection.js";
|
||||||
|
|
||||||
|
|
||||||
|
export default class Component extends HTMLElement {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super();
|
||||||
|
if (options.selection) { this.selection = new Selection(this, options.selection); }
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
if (this.selection) {
|
||||||
|
const parent = this._app.querySelector("footer");
|
||||||
|
this.selection.appendTo(parent);
|
||||||
|
}
|
||||||
|
this._app.addEventListener("load", _ => this._onAppLoad());
|
||||||
|
this._app.addEventListener("component-change", _ => {
|
||||||
|
const component = this._app.component;
|
||||||
|
const isThis = (this.nodeName.toLowerCase() == `cyp-${component}`);
|
||||||
|
this._onComponentChange(component, isThis);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get _app() { return this.closest("cyp-app"); }
|
||||||
|
get _mpd() { return this._app.mpd; }
|
||||||
|
|
||||||
|
_onAppLoad() {}
|
||||||
|
_onComponentChange(_component, _isThis) {}
|
||||||
|
}
|
|
@ -1,3 +1,5 @@
|
||||||
export const artSize = 96;
|
export const artSize = 96 * (window.devicePixelRatio || 1);
|
||||||
export const ytPath = "_youtube";
|
export const ytPath = "_youtube";
|
||||||
export const locale = "cs";
|
export let ytLimit = 3;
|
||||||
|
|
||||||
|
export function setYtLimit(limit) { ytLimit = limit; }
|
20
app/js/cyp.js
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import "./elements/range.js";
|
||||||
|
import "./elements/app.js";
|
||||||
|
import "./elements/menu.js";
|
||||||
|
import "./elements/player.js";
|
||||||
|
import "./elements/queue.js";
|
||||||
|
import "./elements/playlists.js";
|
||||||
|
import "./elements/settings.js";
|
||||||
|
import "./elements/yt.js";
|
||||||
|
import "./elements/song.js";
|
||||||
|
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();
|
126
app/js/elements/app.js
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
import MPD from "../mpd.js";
|
||||||
|
import * as html from "../html.js";
|
||||||
|
|
||||||
|
function initIcons() {
|
||||||
|
Array.from(document.querySelectorAll("[data-icon]")).forEach(/** @param {HTMLElement} node */ node => {
|
||||||
|
node.dataset.icon.split(" ").forEach(name => {
|
||||||
|
let icon = html.icon(name);
|
||||||
|
node.insertBefore(icon, node.firstChild);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class App extends HTMLElement {
|
||||||
|
static get observedAttributes() { return ["component"]; }
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
initIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
await waitForChildren(this);
|
||||||
|
|
||||||
|
window.addEventListener("hashchange", e => this._onHashChange());
|
||||||
|
this._onHashChange();
|
||||||
|
|
||||||
|
await this._connect();
|
||||||
|
this.dispatchEvent(new CustomEvent("load"));
|
||||||
|
|
||||||
|
this._initMediaHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(name, oldValue, newValue) {
|
||||||
|
switch (name) {
|
||||||
|
case "component":
|
||||||
|
location.hash = newValue;
|
||||||
|
const e = new CustomEvent("component-change");
|
||||||
|
this.dispatchEvent(e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
17
app/js/elements/back.js
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import Item from "../item.js";
|
||||||
|
import * as html from "../html.js";
|
||||||
|
|
||||||
|
|
||||||
|
export default class Back extends Item {
|
||||||
|
constructor(title) {
|
||||||
|
super();
|
||||||
|
this._title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.appendChild(html.icon("keyboard-backspace"));
|
||||||
|
this._buildTitle(this._title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("cyp-back", Back);
|
33
app/js/elements/filter.js
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
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);
|
253
app/js/elements/library.js
Normal file
|
@ -0,0 +1,253 @@
|
||||||
|
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);
|
28
app/js/elements/menu.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import Component from "../component.js";
|
||||||
|
|
||||||
|
class Menu extends Component {
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
/** @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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("cyp-menu", Menu);
|
21
app/js/elements/path.js
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import Item from "../item.js";
|
||||||
|
import * as html from "../html.js";
|
||||||
|
import * as format from "../format.js";
|
||||||
|
|
||||||
|
|
||||||
|
export default class Path extends Item {
|
||||||
|
constructor(data) {
|
||||||
|
super();
|
||||||
|
this._data = data;
|
||||||
|
this._isDirectory = ("directory" in this._data);
|
||||||
|
}
|
||||||
|
|
||||||
|
get file() { return (this._isDirectory ? this._data["directory"] : this._data["file"]); }
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.appendChild(html.icon(this._isDirectory ? "folder" : "music"));
|
||||||
|
this._buildTitle(format.fileName(this.file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("cyp-path", Path);
|
179
app/js/elements/player.js
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
import * as art from "../art.js";
|
||||||
|
import * as html from "../html.js";
|
||||||
|
import * as format from "../format.js";
|
||||||
|
import Component from "../component.js";
|
||||||
|
|
||||||
|
|
||||||
|
const ELAPSED_PERIOD = 500;
|
||||||
|
|
||||||
|
class Player extends Component {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._current = {
|
||||||
|
song: {},
|
||||||
|
elapsed: 0,
|
||||||
|
at: 0,
|
||||||
|
volume: 0
|
||||||
|
};
|
||||||
|
this._toggleVolume = 0;
|
||||||
|
|
||||||
|
const DOM = {};
|
||||||
|
const all = this.querySelectorAll("[class]");
|
||||||
|
[...all].forEach(node => DOM[node.className] = node);
|
||||||
|
DOM.progress = DOM.timeline.querySelector("x-range");
|
||||||
|
DOM.volume = DOM.volume.querySelector("x-range");
|
||||||
|
|
||||||
|
this._dom = DOM;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEvent(e) {
|
||||||
|
switch (e.type) {
|
||||||
|
case "idle-change":
|
||||||
|
let hasOptions = e.detail.includes("options");
|
||||||
|
let hasPlayer = e.detail.includes("player");
|
||||||
|
let hasMixer = e.detail.includes("mixer");
|
||||||
|
(hasOptions || hasPlayer || hasMixer) && this._updateStatus();
|
||||||
|
hasPlayer && this._updateCurrent();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onAppLoad() {
|
||||||
|
this._addEvents();
|
||||||
|
this._updateStatus();
|
||||||
|
this._updateCurrent();
|
||||||
|
this._app.addEventListener("idle-change", this);
|
||||||
|
|
||||||
|
setInterval(() => this._updateElapsed(), ELAPSED_PERIOD);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _updateStatus() {
|
||||||
|
const data = await this._mpd.status();
|
||||||
|
|
||||||
|
this._updateFlags(data);
|
||||||
|
this._updateVolume(data);
|
||||||
|
|
||||||
|
// rebase the time sync
|
||||||
|
this._current.elapsed = Number(data["elapsed"] || 0);
|
||||||
|
this._current.at = performance.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _updateCurrent() {
|
||||||
|
const data = await this._mpd.currentSong();
|
||||||
|
const DOM = this._dom;
|
||||||
|
|
||||||
|
if (data["file"] != this._current.song["file"]) { // changed song
|
||||||
|
if (data["file"]) { // is there a song at all?
|
||||||
|
DOM.title.textContent = data["Title"] || format.fileName(data["file"]);
|
||||||
|
DOM.subtitle.textContent = format.subtitle(data, {duration:false});
|
||||||
|
|
||||||
|
let duration = Number(data["duration"]);
|
||||||
|
DOM.duration.textContent = format.time(duration);
|
||||||
|
DOM.progress.max = duration;
|
||||||
|
DOM.progress.disabled = false;
|
||||||
|
} else {
|
||||||
|
DOM.title.textContent = "";
|
||||||
|
DOM.subtitle.textContent = "";
|
||||||
|
DOM.progress.value = 0;
|
||||||
|
DOM.progress.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._dispatchSongChange(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
let artistNew = data["Artist"] || data["AlbumArtist"];
|
||||||
|
let artistOld = this._current.song["Artist"] || this._current.song["AlbumArtist"];
|
||||||
|
let albumNew = data["Album"];
|
||||||
|
let albumOld = this._current.song["Album"];
|
||||||
|
|
||||||
|
Object.assign(this._current.song, data);
|
||||||
|
|
||||||
|
if (artistNew != artistOld || albumNew != albumOld) { // changed album (art)
|
||||||
|
html.clear(DOM.art);
|
||||||
|
let src = await art.get(this._mpd, artistNew, data["Album"], data["file"]);
|
||||||
|
if (src) {
|
||||||
|
html.node("img", {src}, "", DOM.art);
|
||||||
|
} else {
|
||||||
|
html.icon("music", DOM.art);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateElapsed() {
|
||||||
|
const DOM = this._dom;
|
||||||
|
|
||||||
|
let elapsed = 0;
|
||||||
|
if (this._current.song["file"]) {
|
||||||
|
elapsed = this._current.elapsed;
|
||||||
|
if (this.dataset.state == "play") { elapsed += (performance.now() - this._current.at)/1000; }
|
||||||
|
}
|
||||||
|
|
||||||
|
DOM.progress.value = elapsed;
|
||||||
|
DOM.elapsed.textContent = format.time(elapsed);
|
||||||
|
this._app.style.setProperty("--progress", DOM.progress.value/DOM.progress.max);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateFlags(data) {
|
||||||
|
let flags = [];
|
||||||
|
if (data["random"] == "1") { flags.push("random"); }
|
||||||
|
if (data["repeat"] == "1") { flags.push("repeat"); }
|
||||||
|
if (data["volume"] === "0") { flags.push("mute"); } // strict, because volume might be missing
|
||||||
|
this.dataset.flags = flags.join(" ");
|
||||||
|
this.dataset.state = data["state"];
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateVolume(data) {
|
||||||
|
const DOM = this._dom;
|
||||||
|
|
||||||
|
if ("volume" in data) {
|
||||||
|
let volume = Number(data["volume"]);
|
||||||
|
|
||||||
|
DOM.mute.disabled = false;
|
||||||
|
DOM.volume.disabled = false;
|
||||||
|
DOM.volume.value = volume;
|
||||||
|
|
||||||
|
if (volume == 0 && this._current.volume > 0) { this._toggleVolume = this._current.volume; } // muted
|
||||||
|
if (volume > 0 && this._current.volume == 0) { this._toggleVolume = 0; } // restored
|
||||||
|
this._current.volume = volume;
|
||||||
|
} else {
|
||||||
|
DOM.mute.disabled = true;
|
||||||
|
DOM.volume.disabled = true;
|
||||||
|
DOM.volume.value = 50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_addEvents() {
|
||||||
|
const DOM = this._dom;
|
||||||
|
|
||||||
|
DOM.play.addEventListener("click", _ => this._app.mpd.command("play"));
|
||||||
|
DOM.pause.addEventListener("click", _ => this._app.mpd.command("pause 1"));
|
||||||
|
DOM.prev.addEventListener("click", _ => this._app.mpd.command("previous"));
|
||||||
|
DOM.next.addEventListener("click", _ => this._app.mpd.command("next"));
|
||||||
|
|
||||||
|
DOM.random.addEventListener("click", _ => {
|
||||||
|
let isRandom = this.dataset.flags.split(" ").includes("random");
|
||||||
|
this._app.mpd.command(`random ${isRandom ? "0" : "1"}`);
|
||||||
|
});
|
||||||
|
DOM.repeat.addEventListener("click", _ => {
|
||||||
|
let isRepeat = this.dataset.flags.split(" ").includes("repeat");
|
||||||
|
this._app.mpd.command(`repeat ${isRepeat ? "0" : "1"}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
DOM.progress.addEventListener("input", e => {
|
||||||
|
let elapsed = e.target.valueAsNumber;
|
||||||
|
this._current.elapsed = elapsed;
|
||||||
|
this._current.at = performance.now();
|
||||||
|
this._app.mpd.command(`seekcur ${elapsed}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
DOM.volume.addEventListener("input", e => this._app.mpd.command(`setvol ${e.target.valueAsNumber}`));
|
||||||
|
DOM.mute.addEventListener("click", _ => this._app.mpd.command(`setvol ${this._toggleVolume}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
_dispatchSongChange(detail) {
|
||||||
|
const e = new CustomEvent("song-change", {detail});
|
||||||
|
this._app.dispatchEvent(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("cyp-player", Player);
|
16
app/js/elements/playlist.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import * as html from "../html.js";
|
||||||
|
import Item from "../item.js";
|
||||||
|
|
||||||
|
export default class Playlist extends Item {
|
||||||
|
constructor(name) {
|
||||||
|
super();
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
html.icon("playlist-music", this);
|
||||||
|
this._buildTitle(this.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("cyp-playlist", Playlist);
|
68
app/js/elements/playlists.js
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import * as html from "../html.js";
|
||||||
|
import Component from "../component.js";
|
||||||
|
import Playlist from "./playlist.js";
|
||||||
|
import { escape } from "../mpd.js";
|
||||||
|
|
||||||
|
class Playlists extends Component {
|
||||||
|
constructor() {
|
||||||
|
super({selection:"single"});
|
||||||
|
this._initCommands();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEvent(e) {
|
||||||
|
switch (e.type) {
|
||||||
|
case "idle-change":
|
||||||
|
e.detail.includes("stored_playlist") && this._sync();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onAppLoad() {
|
||||||
|
this._app.addEventListener("idle-change", this);
|
||||||
|
this._sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onComponentChange(c, isThis) {
|
||||||
|
this.hidden = !isThis;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _sync() {
|
||||||
|
let lists = await this._mpd.listPlaylists();
|
||||||
|
this._buildLists(lists);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildLists(lists) {
|
||||||
|
html.clear(this);
|
||||||
|
this.selection.clear();
|
||||||
|
|
||||||
|
lists.forEach(name => this.appendChild(new Playlist(name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
_initCommands() {
|
||||||
|
const sel = this.selection;
|
||||||
|
|
||||||
|
sel.addCommand(async item => {
|
||||||
|
const name = item.name;
|
||||||
|
const commands = ["clear", `load "${escape(name)}"`, "play"];
|
||||||
|
await this._mpd.command(commands);
|
||||||
|
this.selection.clear(); // fixme notification?
|
||||||
|
}, {label:"Play", icon:"play"});
|
||||||
|
|
||||||
|
sel.addCommand(async item => {
|
||||||
|
const name = item.name;
|
||||||
|
await this._mpd.command(`load "${escape(name)}"`);
|
||||||
|
this.selection.clear(); // fixme notification?
|
||||||
|
}, {label:"Enqueue", icon:"plus"});
|
||||||
|
|
||||||
|
sel.addCommand(async item => {
|
||||||
|
const name = item.name;
|
||||||
|
if (!confirm(`Really delete playlist '${name}'?`)) { return; }
|
||||||
|
|
||||||
|
await this._mpd.command(`rm "${escape(name)}"`);
|
||||||
|
}, {label:"Delete", icon:"delete"});
|
||||||
|
|
||||||
|
sel.addCommandCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("cyp-playlists", Playlists);
|
122
app/js/elements/queue.js
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
import * as html from "../html.js";
|
||||||
|
import Component from "../component.js";
|
||||||
|
import Song from "./song.js";
|
||||||
|
import { escape } from "../mpd.js";
|
||||||
|
|
||||||
|
|
||||||
|
function generateMoveCommands(items, diff, all) {
|
||||||
|
const COMPARE = (a, b) => all.indexOf(a) - all.indexOf(b);
|
||||||
|
|
||||||
|
return items.sort(COMPARE)
|
||||||
|
.map(item => {
|
||||||
|
let index = all.indexOf(item) + diff;
|
||||||
|
if (index < 0 || index >= all.length) { return null; } // this does not move
|
||||||
|
return `moveid ${item.songId} ${index}`;
|
||||||
|
})
|
||||||
|
.filter(command => command);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Queue extends Component {
|
||||||
|
constructor() {
|
||||||
|
super({selection:"multi"});
|
||||||
|
this._currentId = null;
|
||||||
|
this._initCommands();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEvent(e) {
|
||||||
|
switch (e.type) {
|
||||||
|
case "song-change":
|
||||||
|
this._currentId = e.detail["Id"];
|
||||||
|
this._updateCurrent();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "idle-change":
|
||||||
|
e.detail.includes("playlist") && this._sync();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onAppLoad() {
|
||||||
|
this._app.addEventListener("idle-change", this);
|
||||||
|
this._app.addEventListener("song-change", this);
|
||||||
|
this._sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onComponentChange(c, isThis) {
|
||||||
|
this.hidden = !isThis;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _sync() {
|
||||||
|
let songs = await this._mpd.listQueue();
|
||||||
|
this._buildSongs(songs);
|
||||||
|
|
||||||
|
let e = new CustomEvent("queue-length-change", {detail:songs.length});
|
||||||
|
this._app.dispatchEvent(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateCurrent() {
|
||||||
|
Array.from(this.children).forEach(/** @param {Song} node */ node => {
|
||||||
|
node.playing = (node.songId == this._currentId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildSongs(songs) {
|
||||||
|
html.clear(this);
|
||||||
|
this.selection.clear();
|
||||||
|
|
||||||
|
songs.forEach(song => {
|
||||||
|
const node = new Song(song);
|
||||||
|
this.appendChild(node);
|
||||||
|
|
||||||
|
node.addButton("play", async _ => {
|
||||||
|
await this._mpd.command(`playid ${node.songId}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this._updateCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
_initCommands() {
|
||||||
|
const sel = this.selection;
|
||||||
|
|
||||||
|
sel.addCommandAll();
|
||||||
|
|
||||||
|
sel.addCommand(items => {
|
||||||
|
const commands = generateMoveCommands(items, -1, Array.from(this.children));
|
||||||
|
this._mpd.command(commands);
|
||||||
|
}, {label:"Up", icon:"arrow-up-bold"});
|
||||||
|
|
||||||
|
sel.addCommand(items => {
|
||||||
|
const commands = generateMoveCommands(items, +1, Array.from(this.children));
|
||||||
|
this._mpd.command(commands.reverse()); // move last first
|
||||||
|
}, {label:"Down", icon:"arrow-down-bold"});
|
||||||
|
|
||||||
|
sel.addCommand(async 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();
|
||||||
|
}, {label:"Save", icon:"content-save"});
|
||||||
|
|
||||||
|
sel.addCommand(async items => {
|
||||||
|
if (!confirm(`Remove these ${items.length} songs from the queue?`)) { return; }
|
||||||
|
|
||||||
|
const commands = items.map(item => `deleteid ${item.songId}`);
|
||||||
|
this._mpd.command(commands);
|
||||||
|
}, {label:"Remove", icon:"delete"});
|
||||||
|
|
||||||
|
sel.addCommandCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("cyp-queue", Queue);
|
1
app/js/elements/range.js
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
../../../node_modules/custom-range/range.js
|
33
app/js/elements/search.js
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import * as html from "../html.js";
|
||||||
|
|
||||||
|
export default class Search 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"); }
|
||||||
|
|
||||||
|
onSubmit() {}
|
||||||
|
focus() { this._input.focus(); }
|
||||||
|
pending(pending) { this.classList.toggle("pending", pending); }
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
if (this._built) { return; }
|
||||||
|
|
||||||
|
const form = html.node("form", {}, "", this);
|
||||||
|
html.node("input", {type:"text"}, "", form);
|
||||||
|
html.button({icon:"magnify"}, "", form);
|
||||||
|
|
||||||
|
form.addEventListener("submit", e => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.onSubmit();
|
||||||
|
});
|
||||||
|
|
||||||
|
this._built = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("cyp-search", Search);
|
83
app/js/elements/settings.js
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import Component from "../component.js";
|
||||||
|
import * as conf from "../conf.js";
|
||||||
|
|
||||||
|
|
||||||
|
const prefix = "cyp";
|
||||||
|
|
||||||
|
function loadFromStorage(key) {
|
||||||
|
return localStorage.getItem(`${prefix}-${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToStorage(key, value) {
|
||||||
|
return localStorage.setItem(`${prefix}-${key}`, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Settings extends Component {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._inputs = {
|
||||||
|
theme: this.querySelector("[name=theme]"),
|
||||||
|
ytLimit: this.querySelector("[name=yt-limit]"),
|
||||||
|
color: Array.from(this.querySelectorAll("[name=color]"))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_onAppLoad() {
|
||||||
|
let mo = new MutationObserver(mrs => {
|
||||||
|
mrs.forEach(mr => this._onAppAttributeChange(mr));
|
||||||
|
});
|
||||||
|
mo.observe(this._app, {attributes:true});
|
||||||
|
|
||||||
|
this._inputs.theme.addEventListener("change", e => this._setTheme(e.target.value));
|
||||||
|
this._inputs.ytLimit.addEventListener("change", e => this._setYtLimit(e.target.value));
|
||||||
|
this._inputs.color.forEach(input => {
|
||||||
|
input.addEventListener("click", e => this._setColor(e.target.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
const theme = loadFromStorage("theme");
|
||||||
|
(theme ? this._app.setAttribute("theme", theme) : this._syncTheme());
|
||||||
|
|
||||||
|
const color = loadFromStorage("color");
|
||||||
|
(color ? this._app.setAttribute("color", color) : this._syncColor());
|
||||||
|
|
||||||
|
const ytLimit = loadFromStorage("ytLimit") || conf.ytLimit;
|
||||||
|
this._setYtLimit(ytLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onAppAttributeChange(mr) {
|
||||||
|
if (mr.attributeName == "theme") { this._syncTheme(); }
|
||||||
|
if (mr.attributeName == "color") { this._syncColor(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncTheme() {
|
||||||
|
this._inputs.theme.value = this._app.getAttribute("theme");
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncColor() {
|
||||||
|
this._inputs.color.forEach(input => {
|
||||||
|
input.checked = (input.value == this._app.getAttribute("color"));
|
||||||
|
input.parentNode.style.color = input.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_setTheme(theme) {
|
||||||
|
saveToStorage("theme", theme);
|
||||||
|
this._app.setAttribute("theme", theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
_setColor(color) {
|
||||||
|
saveToStorage("color", color);
|
||||||
|
this._app.setAttribute("color", color);
|
||||||
|
}
|
||||||
|
|
||||||
|
_setYtLimit(ytLimit) {
|
||||||
|
saveToStorage("ytLimit", ytLimit);
|
||||||
|
conf.setYtLimit(ytLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onComponentChange(c, isThis) {
|
||||||
|
this.hidden = !isThis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("cyp-settings", Settings);
|
47
app/js/elements/song.js
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import * as format from "../format.js";
|
||||||
|
import * as html from "../html.js";
|
||||||
|
import Item from "../item.js";
|
||||||
|
|
||||||
|
export default class Song extends Item {
|
||||||
|
constructor(data) {
|
||||||
|
super();
|
||||||
|
this._data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
get file() { return this._data["file"]; }
|
||||||
|
get songId() { return this._data["Id"]; }
|
||||||
|
|
||||||
|
set playing(playing) {
|
||||||
|
this.classList.toggle("playing", playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
const data = this._data;
|
||||||
|
|
||||||
|
html.icon("music", this);
|
||||||
|
html.icon("play", this);
|
||||||
|
|
||||||
|
const block = html.node("div", {className:"multiline"}, "", this);
|
||||||
|
|
||||||
|
const title = this._buildTitle(data);
|
||||||
|
block.appendChild(title);
|
||||||
|
if (data["Track"]) {
|
||||||
|
const track = html.node("span", {className:"track"}, data["Track"].padStart(2, "0"));
|
||||||
|
title.insertBefore(html.text(" "), title.firstChild);
|
||||||
|
title.insertBefore(track, title.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data["Title"]) {
|
||||||
|
const subtitle = format.subtitle(data);
|
||||||
|
html.node("span", {className:"subtitle"}, subtitle, block);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.playing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildTitle(data) {
|
||||||
|
return super._buildTitle(data["Title"] || format.fileName(this.file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("cyp-song", Song);
|
56
app/js/elements/tag.js
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import * as html from "../html.js";
|
||||||
|
import * as art from "../art.js";
|
||||||
|
import Item from "../item.js";
|
||||||
|
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
"AlbumArtist": "artist",
|
||||||
|
"Album": "album",
|
||||||
|
"Genre": "music"
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Tag extends Item {
|
||||||
|
constructor(type, value, filter) {
|
||||||
|
super();
|
||||||
|
this._type = type;
|
||||||
|
this._value = value;
|
||||||
|
this._filter = filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
html.node("span", {className:"art"}, "", this);
|
||||||
|
this._buildTitle(this._value);
|
||||||
|
}
|
||||||
|
|
||||||
|
createChildFilter() {
|
||||||
|
return Object.assign({[this._type]:this._value}, this._filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fillArt(mpd) {
|
||||||
|
const parent = this.firstChild;
|
||||||
|
const filter = this.createChildFilter();
|
||||||
|
|
||||||
|
let artist = filter["AlbumArtist"];
|
||||||
|
let album = filter["Album"];
|
||||||
|
let src = null;
|
||||||
|
|
||||||
|
if (artist && album) {
|
||||||
|
src = await art.get(mpd, artist, album);
|
||||||
|
if (!src) {
|
||||||
|
let songs = await mpd.listSongs(filter, [0,1]);
|
||||||
|
if (songs.length) {
|
||||||
|
src = await art.get(mpd, artist, album, songs[0]["file"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (src) {
|
||||||
|
html.node("img", {src}, "", parent);
|
||||||
|
} else {
|
||||||
|
const icon = ICONS[this._type];
|
||||||
|
html.icon(icon, parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("cyp-tag", Tag);
|
19
app/js/elements/yt-result.js
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import Item from "../item.js";
|
||||||
|
import * as html from "../html.js";
|
||||||
|
|
||||||
|
|
||||||
|
export default class YtResult extends Item {
|
||||||
|
constructor(title) {
|
||||||
|
super();
|
||||||
|
this._title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.appendChild(html.icon("magnify"));
|
||||||
|
this._buildTitle(this._title);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("cyp-yt-result", YtResult);
|
94
app/js/elements/yt.js
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import * as html from "../html.js";
|
||||||
|
import * as conf from "../conf.js";
|
||||||
|
import { escape } from "../mpd.js";
|
||||||
|
import Component from "../component.js";
|
||||||
|
import Search from "./search.js";
|
||||||
|
import Result from "./yt-result.js";
|
||||||
|
|
||||||
|
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
|
||||||
|
function decodeChunk(byteArray) {
|
||||||
|
// \r => \n
|
||||||
|
return decoder.decode(byteArray).replace(/\u000d/g, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
class YT extends Component {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._search = new Search();
|
||||||
|
|
||||||
|
this._search.onSubmit = _ => {
|
||||||
|
let query = this._search.value;
|
||||||
|
query && this._doSearch(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
this._clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
_clear() {
|
||||||
|
html.clear(this);
|
||||||
|
this.appendChild(this._search);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _doSearch(query) {
|
||||||
|
this._clear();
|
||||||
|
this._search.pending(true);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._search.pending(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async _download(id) {
|
||||||
|
this._clear();
|
||||||
|
|
||||||
|
let pre = html.node("pre", {}, "", this);
|
||||||
|
this._search.pending(true);
|
||||||
|
|
||||||
|
let body = new URLSearchParams();
|
||||||
|
body.set("id", id);
|
||||||
|
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._search.pending(false);
|
||||||
|
|
||||||
|
if (response.status == 200) {
|
||||||
|
this._mpd.command(`update ${escape(conf.ytPath)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onComponentChange(c, isThis) {
|
||||||
|
const wasHidden = this.hidden;
|
||||||
|
this.hidden = !isThis;
|
||||||
|
|
||||||
|
if (!wasHidden && isThis) { this._clear(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("cyp-yt", YT);
|
|
@ -9,8 +9,19 @@ export function time(sec) {
|
||||||
|
|
||||||
export function subtitle(data, options = {duration:true}) {
|
export function subtitle(data, options = {duration:true}) {
|
||||||
let tokens = [];
|
let tokens = [];
|
||||||
data["Artist"] && tokens.push(data["Artist"]);
|
|
||||||
|
if (data["Artist"]) {
|
||||||
|
tokens.push(data["Artist"]);
|
||||||
|
} else if (data["AlbumArtist"]) {
|
||||||
|
tokens.push(data["AlbumArtist"]);
|
||||||
|
}
|
||||||
|
|
||||||
data["Album"] && tokens.push(data["Album"]);
|
data["Album"] && tokens.push(data["Album"]);
|
||||||
options.duration && data["duration"] && tokens.push(time(Number(data["duration"])));
|
options.duration && data["duration"] && tokens.push(time(Number(data["duration"])));
|
||||||
|
|
||||||
return tokens.join(SEPARATOR);
|
return tokens.join(SEPARATOR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fileName(file) {
|
||||||
|
return file.split("/").pop();
|
||||||
|
}
|
73
app/js/fs.js
|
@ -1,73 +0,0 @@
|
||||||
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";
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
89
app/js/icons.js
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
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"/>
|
||||||
|
</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"/>
|
||||||
|
</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"/>
|
||||||
|
</svg>`;
|
||||||
|
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"/>
|
||||||
|
</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"/>
|
||||||
|
</svg>`;
|
||||||
|
ICONS["fast-forward"] = `<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M13,6V18L21.5,12M4,18L12.5,12L4,6V18Z"/>
|
||||||
|
</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"/>
|
||||||
|
</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"/>
|
||||||
|
</svg>`;
|
||||||
|
export default ICONS;
|
26
app/js/item.js
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import * as html from "./html.js";
|
||||||
|
|
||||||
|
|
||||||
|
export default class Item extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.addEventListener("click", _ => this.onClick());
|
||||||
|
}
|
||||||
|
|
||||||
|
addButton(icon, cb) {
|
||||||
|
html.button({icon}, "", this).addEventListener("click", e => {
|
||||||
|
e.stopPropagation(); // do not select
|
||||||
|
cb();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick() { this.parentNode.selection.toggle(this); }
|
||||||
|
|
||||||
|
_buildTitle(title) {
|
||||||
|
return html.node("span", {className:"title"}, title, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
matchPrefix(prefix) {
|
||||||
|
return this.textContent.match(/\w+/g).some(word => word.toLowerCase().startsWith(prefix));
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,63 +0,0 @@
|
||||||
import * as mpd from "./mpd.js";
|
|
||||||
import * as parser from "./parser.js";
|
|
||||||
import * as html from "./html.js";
|
|
||||||
import * as conf from "../conf.js";
|
|
||||||
|
|
||||||
let cache = {};
|
|
||||||
const MIME = "image/jpeg";
|
|
||||||
const STORAGE_PREFIX = `art-${conf.artSize}` ;
|
|
||||||
|
|
||||||
function store(key, data) {
|
|
||||||
localStorage.setItem(`${STORAGE_PREFIX}-${key}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
function load(key) {
|
|
||||||
return localStorage.getItem(`${STORAGE_PREFIX}-${key}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bytesToImage(bytes) {
|
|
||||||
let blob = new Blob([bytes]);
|
|
||||||
let src = URL.createObjectURL(blob);
|
|
||||||
let 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");
|
|
||||||
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
||||||
return canvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function get(artist, album, songUrl = null) {
|
|
||||||
let key = `${artist}-${album}`;
|
|
||||||
if (key in cache) { return cache[key]; }
|
|
||||||
|
|
||||||
let loaded = await load(key);
|
|
||||||
if (loaded) {
|
|
||||||
cache[key] = loaded;
|
|
||||||
return loaded;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!songUrl) { return null; }
|
|
||||||
|
|
||||||
// promise to be returned in the meantime
|
|
||||||
let resolve;
|
|
||||||
let 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);
|
|
||||||
store(key, url);
|
|
||||||
cache[key] = url;
|
|
||||||
resolve(url);
|
|
||||||
} catch (e) {
|
|
||||||
cache[key] = null;
|
|
||||||
}
|
|
||||||
return cache[key];
|
|
||||||
}
|
|
|
@ -1,98 +0,0 @@
|
||||||
let ICONS={};
|
|
||||||
ICONS["playlist-music"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" 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["play-circle-outline"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M10,16.5L16,12L10,7.5V16.5Z"/>
|
|
||||||
</svg>`;
|
|
||||||
ICONS["folder"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" 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 xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" 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 xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" 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 xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"/>
|
|
||||||
</svg>`;
|
|
||||||
ICONS["minus-circle"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M17,13H7V11H17M12,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["magnify"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" 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["rewind"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M11.5,12L20,18V6M11,18V6L2.5,12L11,18Z"/>
|
|
||||||
</svg>`;
|
|
||||||
ICONS["account-multiple"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" 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["settings"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" 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"/>
|
|
||||||
</svg>`;
|
|
||||||
ICONS["pause"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M14,19H18V5H14M6,19H10V5H6V19Z"/>
|
|
||||||
</svg>`;
|
|
||||||
ICONS["pause-circle"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M15,16H13V8H15M11,16H9V8H11M12,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["close-circle-outline"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2C6.47,2 2,6.47 2,12C2,17.53 6.47,22 12,22C17.53,22 22,17.53 22,12C22,6.47 17.53,2 12,2M14.59,8L12,10.59L9.41,8L8,9.41L10.59,12L8,14.59L9.41,16L12,13.41L14.59,16L16,14.59L13.41,12L16,9.41L14.59,8Z"/>
|
|
||||||
</svg>`;
|
|
||||||
ICONS["volume-off"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" 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 xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" 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 xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" 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["minus"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M19,13H5V11H19V13Z"/>
|
|
||||||
</svg>`;
|
|
||||||
ICONS["close-circle"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z"/>
|
|
||||||
</svg>`;
|
|
||||||
ICONS["repeat"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M17,17H7V14L3,18L7,22V19H19V13H17M7,7H17V10L21,6L17,2V5H5V11H7V7Z"/>
|
|
||||||
</svg>`;
|
|
||||||
ICONS["play"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M8,5.14V19.14L19,12.14L8,5.14Z"/>
|
|
||||||
</svg>`;
|
|
||||||
ICONS["pause-circle-outline"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M13,16V8H15V16H13M9,16V8H11V16H9M12,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,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z"/>
|
|
||||||
</svg>`;
|
|
||||||
ICONS["plus"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z"/>
|
|
||||||
</svg>`;
|
|
||||||
ICONS["content-save"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" 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 xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" 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["fast-forward"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M13,6V18L21.5,12M4,18L12.5,12L4,6V18Z"/>
|
|
||||||
</svg>`;
|
|
||||||
ICONS["minus-circle-outline"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M7,13H17V11H7"/>
|
|
||||||
</svg>`;
|
|
||||||
ICONS["volume-high"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" 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["album"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" 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["plus-circle-outline"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M13,7H11V11H7V13H11V17H13V13H17V11H13V7Z"/>
|
|
||||||
</svg>`;
|
|
||||||
ICONS["plus-circle"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M17,13H13V17H11V13H7V11H11V7H13V11H17M12,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["play-circle"] = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24">
|
|
||||||
<path d="M10,16.5V7.5L16,12M12,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>`;
|
|
||||||
export default ICONS;
|
|
|
@ -1,154 +0,0 @@
|
||||||
import * as parser from "./parser.js";
|
|
||||||
|
|
||||||
let ws;
|
|
||||||
let commandQueue = [];
|
|
||||||
let current;
|
|
||||||
|
|
||||||
function onMessage(e) {
|
|
||||||
if (current) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
processQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onError(e) {
|
|
||||||
console.error(e);
|
|
||||||
current && current.reject(e);
|
|
||||||
ws = null; // fixme
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClose(e) {
|
|
||||||
console.warn(e);
|
|
||||||
current && current.reject(e);
|
|
||||||
ws = null; // fixme
|
|
||||||
}
|
|
||||||
|
|
||||||
function processQueue() {
|
|
||||||
if (current || commandQueue.length == 0) { return; }
|
|
||||||
current = commandQueue.shift();
|
|
||||||
ws.send(current.cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeFilter(filter) {
|
|
||||||
let tokens = ["("];
|
|
||||||
Object.entries(filter).forEach(([key, value], index) => {
|
|
||||||
index && tokens.push(" AND ");
|
|
||||||
tokens.push(`(${key} == "${escape(value)}")`);
|
|
||||||
});
|
|
||||||
tokens.push(")");
|
|
||||||
|
|
||||||
let filterStr = tokens.join("");
|
|
||||||
return `"${escape(filterStr)}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function escape(str) {
|
|
||||||
return str.replace(/(['"\\])/g, "\\$1");
|
|
||||||
}
|
|
||||||
|
|
||||||
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});
|
|
||||||
processQueue();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function commandAndStatus(cmd) {
|
|
||||||
let lines = await command([cmd, "status", "currentsong"]);
|
|
||||||
let status = parser.linesToStruct(lines);
|
|
||||||
if (status["duration"] instanceof Array) { status["duration"] = status["duration"][0]; }
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function status() {
|
|
||||||
let lines = await command(["status", "currentsong"]);
|
|
||||||
let status = parser.linesToStruct(lines);
|
|
||||||
if (status["duration"] instanceof Array) { status["duration"] = status["duration"][0]; }
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 enqueueByFilter(filter, sort = null) {
|
|
||||||
let tokens = ["findadd"];
|
|
||||||
tokens.push(serializeFilter(filter));
|
|
||||||
// sort && tokens.push("sort", sort); FIXME not implemented in MPD
|
|
||||||
return command(tokens.join(" "));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listPath(path) {
|
|
||||||
let lines = await command(`lsinfo "${escape(path)}"`);
|
|
||||||
return parser.pathContents(lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listTags(tag, filter = null) {
|
|
||||||
let tokens = ["list", tag];
|
|
||||||
if (filter) {
|
|
||||||
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"];
|
|
||||||
tokens.push(serializeFilter(filter));
|
|
||||||
if (window) { tokens.push("window", window.join(":")); }
|
|
||||||
let lines = await command(tokens.join(" "));
|
|
||||||
return parser.songList(lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function albumArt(songUrl) {
|
|
||||||
let data = [];
|
|
||||||
let offset = 0;
|
|
||||||
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"]);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function init() {
|
|
||||||
let response = await fetch("/ticket", {method:"POST"});
|
|
||||||
let ticket = (await response.json()).ticket;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
ws = new WebSocket(`ws://${location.host}/?ticket=${encodeURIComponent(ticket)}`);
|
|
||||||
} catch (e) { reject(e); }
|
|
||||||
current = {resolve, reject};
|
|
||||||
|
|
||||||
ws.addEventListener("error", onError);
|
|
||||||
ws.addEventListener("message", onMessage);
|
|
||||||
ws.addEventListener("close", onClose);
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
let storage = new Map();
|
|
||||||
|
|
||||||
export function publish(message, publisher, data) {
|
|
||||||
console.log(message, publisher, data);
|
|
||||||
if (!storage.has(message)) { return; }
|
|
||||||
storage.get(message).forEach(listener => listener(message, publisher, data));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribe(message, listener) {
|
|
||||||
if (!storage.has(message)) { storage.set(message, new Set()); }
|
|
||||||
storage.get(message).add(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unsubscribe(message, listener) {
|
|
||||||
if (!storage.has(message)) { storage.set(message, new Set()); }
|
|
||||||
storage.get(message).remove(listener);
|
|
||||||
}
|
|
|
@ -1,168 +0,0 @@
|
||||||
import * as html from "./html.js";
|
|
||||||
|
|
||||||
class Range extends HTMLElement {
|
|
||||||
static get observedAttributes() { return ["min", "max", "value", "step", "disabled"]; }
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this._dom = {};
|
|
||||||
|
|
||||||
this.innerHTML = `
|
|
||||||
<span class="-track"></span>
|
|
||||||
<span class="-elapsed"></span>
|
|
||||||
<span class="-remaining"></span>
|
|
||||||
<div class="-inner">
|
|
||||||
<button class="-thumb"></button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
Array.from(this.querySelectorAll("[class^='-']")).forEach(node => {
|
|
||||||
let name = node.className.substring(1);
|
|
||||||
this._dom[name] = node;
|
|
||||||
});
|
|
||||||
|
|
||||||
this._update();
|
|
||||||
|
|
||||||
this.addEventListener("mousedown", this);
|
|
||||||
this.addEventListener("keydown", this);
|
|
||||||
}
|
|
||||||
|
|
||||||
get _valueAsNumber() {
|
|
||||||
let raw = (this.hasAttribute("value") ? Number(this.getAttribute("value")) : 50);
|
|
||||||
return this._constrain(raw);
|
|
||||||
}
|
|
||||||
get _minAsNumber() {
|
|
||||||
return (this.hasAttribute("min") ? Number(this.getAttribute("min")) : 0);
|
|
||||||
}
|
|
||||||
get _maxAsNumber() {
|
|
||||||
return (this.hasAttribute("max") ? Number(this.getAttribute("max")) : 100);
|
|
||||||
}
|
|
||||||
get _stepAsNumber() {
|
|
||||||
return (this.hasAttribute("step") ? Number(this.getAttribute("step")) : 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
get value() { return String(this._valueAsNumber); }
|
|
||||||
get valueAsNumber() { return this._valueAsNumber; }
|
|
||||||
get min() { return this.hasAttribute("min") ? this.getAttribute("min") : ""; }
|
|
||||||
get max() { return this.hasAttribute("max") ? this.getAttribute("max") : ""; }
|
|
||||||
get step() { return this.hasAttribute("step") ? this.getAttribute("step") : ""; }
|
|
||||||
get disabled() { return this.hasAttribute("disabled"); }
|
|
||||||
|
|
||||||
set _valueAsNumber(value) { this.value = String(value); }
|
|
||||||
set min(min) { this.setAttribute("min", min); }
|
|
||||||
set max(max) { this.setAttribute("max", max); }
|
|
||||||
set value(value) { this.setAttribute("value", value); }
|
|
||||||
set step(step) { this.setAttribute("step", step); }
|
|
||||||
set disabled(disabled) {
|
|
||||||
disabled ? this.setAttribute("disabled", "") : this.removeAttribute("disabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
attributeChangedCallback(name, oldValue, newValue) {
|
|
||||||
switch (name) {
|
|
||||||
case "min":
|
|
||||||
case "max":
|
|
||||||
case "value":
|
|
||||||
case "step":
|
|
||||||
this._update();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleEvent(e) {
|
|
||||||
switch (e.type) {
|
|
||||||
case "mousedown":
|
|
||||||
if (this.disabled) { return; }
|
|
||||||
document.addEventListener("mousemove", this);
|
|
||||||
document.addEventListener("mouseup", this);
|
|
||||||
this._setToMouse(e);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "mousemove":
|
|
||||||
this._setToMouse(e);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "mouseup":
|
|
||||||
document.removeEventListener("mousemove", this);
|
|
||||||
document.removeEventListener("mouseup", this);
|
|
||||||
this.dispatchEvent(new CustomEvent("change"));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "keydown":
|
|
||||||
if (this.disabled) { return; }
|
|
||||||
this._handleKey(e.code);
|
|
||||||
this.dispatchEvent(new CustomEvent("input"));
|
|
||||||
this.dispatchEvent(new CustomEvent("change"));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleKey(code) {
|
|
||||||
let min = this._minAsNumber;
|
|
||||||
let max = this._maxAsNumber;
|
|
||||||
let range = max - min;
|
|
||||||
let step = this._stepAsNumber;
|
|
||||||
|
|
||||||
switch (code) {
|
|
||||||
case "ArrowLeft":
|
|
||||||
case "ArrowDown":
|
|
||||||
this._valueAsNumber = this._constrain(this._valueAsNumber - step);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "ArrowRight":
|
|
||||||
case "ArrowUp":
|
|
||||||
this._valueAsNumber = this._constrain(this._valueAsNumber + step);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "Home": this._valueAsNumber = this._constrain(min); break;
|
|
||||||
case "End": this._valueAsNumber = this._constrain(max); break;
|
|
||||||
|
|
||||||
case "PageUp": this._valueAsNumber = this._constrain(this._valueAsNumber + range/10); break;
|
|
||||||
case "PageDown": this._valueAsNumber = this._constrain(this._valueAsNumber - range/10); break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_constrain(value) {
|
|
||||||
const min = this._minAsNumber;
|
|
||||||
const max = this._maxAsNumber;
|
|
||||||
const step = this._stepAsNumber;
|
|
||||||
|
|
||||||
value = Math.max(value, min);
|
|
||||||
value = Math.min(value, max);
|
|
||||||
|
|
||||||
value -= min;
|
|
||||||
value = Math.round(value / step) * step;
|
|
||||||
value += min;
|
|
||||||
if (value > max) { value -= step; }
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
_update() {
|
|
||||||
let min = this._minAsNumber;
|
|
||||||
let max = this._maxAsNumber;
|
|
||||||
let frac = (this._valueAsNumber-min) / (max-min);
|
|
||||||
this._dom.thumb.style.left = `${frac * 100}%`;
|
|
||||||
this._dom.remaining.style.left = `${frac * 100}%`;
|
|
||||||
this._dom.elapsed.style.width = `${frac * 100}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
_setToMouse(e) {
|
|
||||||
let rect = this._dom.inner.getBoundingClientRect();
|
|
||||||
let x = e.clientX;
|
|
||||||
x = Math.max(x, rect.left);
|
|
||||||
x = Math.min(x, rect.right);
|
|
||||||
|
|
||||||
let min = this._minAsNumber;
|
|
||||||
let max = this._maxAsNumber;
|
|
||||||
|
|
||||||
let frac = (x-rect.left) / (rect.right-rect.left);
|
|
||||||
let value = this._constrain(min + frac * (max-min));
|
|
||||||
if (value == this._valueAsNumber) { return; }
|
|
||||||
|
|
||||||
this._valueAsNumber = value;
|
|
||||||
this.dispatchEvent(new CustomEvent("input"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('x-range', Range);
|
|
|
@ -1,43 +0,0 @@
|
||||||
import * as html from "./html.js";
|
|
||||||
import * as conf from "../conf.js";
|
|
||||||
|
|
||||||
const OPEN = "open";
|
|
||||||
const collator = new Intl.Collator(conf.locale, {usage:"search", sensitivity:"base"});
|
|
||||||
|
|
||||||
export default class Search extends EventTarget {
|
|
||||||
constructor(parent) {
|
|
||||||
super();
|
|
||||||
this._node = html.node("label", {className:"search"});
|
|
||||||
|
|
||||||
this._input = html.node("input", {type:"text"}, "", this._node);
|
|
||||||
html.icon("magnify", this._node);
|
|
||||||
|
|
||||||
this._node.addEventListener("click", e => {
|
|
||||||
if (e.target == this._input) { return; }
|
|
||||||
if (this._node.classList.contains(OPEN)) {
|
|
||||||
this.reset();
|
|
||||||
this.dispatchEvent(new Event("input"));
|
|
||||||
} else {
|
|
||||||
this._node.classList.add(OPEN);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this._input.addEventListener("input", e => {
|
|
||||||
this.dispatchEvent(new Event("input"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getNode() { return this._node; }
|
|
||||||
|
|
||||||
match(str) {
|
|
||||||
let q = this._input.value.trim();
|
|
||||||
if (!q) { return true; }
|
|
||||||
let len = q.length;
|
|
||||||
return str.split(" ").some(str => collator.compare(q, str.substring(0, len)) == 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this._input.value = "";
|
|
||||||
this._node.classList.remove(OPEN);
|
|
||||||
}
|
|
||||||
}
|
|
196
app/js/lib/ui.js
|
@ -1,196 +0,0 @@
|
||||||
import * as mpd from "./mpd.js";
|
|
||||||
import * as html from "./html.js";
|
|
||||||
import * as pubsub from "./pubsub.js";
|
|
||||||
import * as format from "./format.js";
|
|
||||||
import * as art from "./art.js";
|
|
||||||
import * as player from "../player.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");
|
|
||||||
}
|
|
||||||
player.update();
|
|
||||||
});
|
|
||||||
|
|
||||||
return button;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteButton(type, id, parent) {
|
|
||||||
let title;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case TYPE_ID: title = "Delete from queue"; break;
|
|
||||||
case TYPE_PLAYLIST: title = "Delete playlist"; break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let button = html.button({icon:"close", title}, "", parent);
|
|
||||||
button.addEventListener("click", async e => {
|
|
||||||
switch (type) {
|
|
||||||
case TYPE_ID:
|
|
||||||
await mpd.command(`deleteid ${id}`);
|
|
||||||
pubsub.publish("queue-change");
|
|
||||||
return;
|
|
||||||
case TYPE_PLAYLIST:
|
|
||||||
let ok = confirm(`Really delete playlist '${id}'?`);
|
|
||||||
if (!ok) { return; }
|
|
||||||
await mpd.command(`rm "${mpd.escape(id)}"`);
|
|
||||||
pubsub.publish("playlists-change");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function playlist(name, parent) {
|
|
||||||
let node = html.node("li", {}, "", parent);
|
|
||||||
|
|
||||||
let info = html.node("span", {className:"info"}, "", node);
|
|
||||||
html.icon("playlist-music", info)
|
|
||||||
html.node("h2", {}, name, info);
|
|
||||||
|
|
||||||
playButton(TYPE_PLAYLIST, name, node);
|
|
||||||
addButton(TYPE_PLAYLIST, name, node);
|
|
||||||
deleteButton(TYPE_PLAYLIST, name, node);
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
77
app/js/mpd-mock.js
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
export function command(cmd) {
|
||||||
|
console.warn(`mpd-mock does not know "${cmd}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function status() {
|
||||||
|
return {
|
||||||
|
volume: 50,
|
||||||
|
elapsed: 10,
|
||||||
|
duration: 70,
|
||||||
|
state: "play"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentSong() {
|
||||||
|
return {
|
||||||
|
duration: 70,
|
||||||
|
file: "name.mp3",
|
||||||
|
Title: "Title of song",
|
||||||
|
Artist: "Artist of song",
|
||||||
|
Album: "Album of song",
|
||||||
|
Track: "6",
|
||||||
|
Id: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listQueue() {
|
||||||
|
return [
|
||||||
|
{Id:1, Track:"5", Title:"Title 1", Artist:"AAA", Album:"BBB", duration:30, file:"a.mp3"},
|
||||||
|
currentSong(),
|
||||||
|
{Id:3, Track:"7", Title:"Title 3", Artist:"CCC", Album:"DDD", duration:230, file:"c.mp3"},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPlaylists() {
|
||||||
|
return [
|
||||||
|
"Playlist 1",
|
||||||
|
"Playlist 2",
|
||||||
|
"Playlist 3"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPath(path) {
|
||||||
|
return {
|
||||||
|
"directory": [
|
||||||
|
{"directory": "Dir 1"},
|
||||||
|
{"directory": "Dir 2"},
|
||||||
|
{"directory": "Dir 3"}
|
||||||
|
],
|
||||||
|
"file": [
|
||||||
|
{"file": "File 1"},
|
||||||
|
{"file": "File 2"},
|
||||||
|
{"file": "File 3"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTags(tag, filter = null) {
|
||||||
|
switch (tag) {
|
||||||
|
case "AlbumArtist": return ["Artist 1", "Artist 2", "Artist 3"];
|
||||||
|
case "Album": return ["Album 1", "Album 2", "Album 3"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSongs(filter, window = null) {
|
||||||
|
return listQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchSongs(filter) {
|
||||||
|
return listQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function albumArt(songUrl) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function init() {}
|
188
app/js/mpd.js
Normal file
|
@ -0,0 +1,188 @@
|
||||||
|
import * as parser from "./parser.js";
|
||||||
|
|
||||||
|
|
||||||
|
export default class MPD {
|
||||||
|
static async connect() {
|
||||||
|
let response = await fetch("/ticket", {method:"POST"});
|
||||||
|
let ticket = (await response.json()).ticket;
|
||||||
|
|
||||||
|
let ws = new WebSocket(createURL(ticket).href);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let mpd;
|
||||||
|
let initialCommand = {resolve: () => resolve(mpd), reject};
|
||||||
|
mpd = new this(ws, initialCommand);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function escape(str) {
|
||||||
|
return str.replace(/(['"\\])/g, "\\$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeFilter(filter, operator = "==") {
|
||||||
|
let tokens = ["("];
|
||||||
|
Object.entries(filter).forEach(([key, value], index) => {
|
||||||
|
index && tokens.push(" AND ");
|
||||||
|
tokens.push(`(${key} ${operator} "${escape(value)}")`);
|
||||||
|
});
|
||||||
|
tokens.push(")");
|
||||||
|
|
||||||
|
let filterStr = tokens.join("");
|
||||||
|
return `"${escape(filterStr)}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createURL(ticket) {
|
||||||
|
let url = new URL(location.href);
|
||||||
|
url.protocol = "ws";
|
||||||
|
url.hash = "";
|
||||||
|
url.searchParams.set("ticket", ticket);
|
||||||
|
return url;
|
||||||
|
}
|
|
@ -1,16 +0,0 @@
|
||||||
import * as app from "./app.js";
|
|
||||||
|
|
||||||
let tabs = [];
|
|
||||||
|
|
||||||
export function init(node) {
|
|
||||||
tabs = Array.from(node.querySelectorAll("[data-for]"));
|
|
||||||
tabs.forEach(tab => {
|
|
||||||
tab.addEventListener("click", e => app.activate(tab.dataset.for));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function active(id) {
|
|
||||||
tabs.forEach(tab => {
|
|
||||||
tab.classList.toggle("active", tab.dataset.for == id);
|
|
||||||
});
|
|
||||||
}
|
|