├── .dockerignore
├── .github
└── workflows
│ └── publish.yml
├── .gitignore
├── .npmrc
├── Dockerfile
├── LICENSE.txt
├── Makefile
├── README.md
├── _youtube
└── .empty
├── app
├── css
│ ├── art.less
│ ├── cyp.less
│ ├── elements
│ │ ├── app.less
│ │ ├── back.less
│ │ ├── filter.less
│ │ ├── library.less
│ │ ├── menu.less
│ │ ├── path.less
│ │ ├── player.less
│ │ ├── playlist.less
│ │ ├── playlists.less
│ │ ├── queue.less
│ │ ├── range.less
│ │ ├── search.less
│ │ ├── settings.less
│ │ ├── song.less
│ │ ├── tag.less
│ │ ├── yt-result.less
│ │ └── yt.less
│ ├── font.less
│ ├── icons.less
│ ├── mixins.less
│ └── variables.less
├── cyp.css
├── cyp.js
├── font
│ ├── LatoLatin-Bold.woff2
│ └── LatoLatin-Regular.woff2
├── icons
│ ├── album.svg
│ ├── arrow-down-bold.svg
│ ├── arrow-up-bold.svg
│ ├── artist.svg
│ ├── cancel.svg
│ ├── checkbox-marked-outline.svg
│ ├── chevron-double-right.svg
│ ├── close.svg
│ ├── content-save.svg
│ ├── delete.svg
│ ├── download.svg
│ ├── fast-forward.svg
│ ├── filter-variant.svg
│ ├── folder.svg
│ ├── keyboard-backspace.svg
│ ├── library-music.svg
│ ├── magnify.svg
│ ├── minus_unused.svg
│ ├── music.svg
│ ├── pause.svg
│ ├── play.svg
│ ├── playlist-music.svg
│ ├── plus.svg
│ ├── repeat.svg
│ ├── rewind.svg
│ ├── settings.svg
│ ├── shuffle.svg
│ ├── volume-high.svg
│ └── volume-off.svg
├── index.html
├── js
│ ├── art.ts
│ ├── component.ts
│ ├── conf.ts
│ ├── cyp.ts
│ ├── elements
│ │ ├── app.ts
│ │ ├── back.ts
│ │ ├── filter.ts
│ │ ├── library.ts
│ │ ├── menu.ts
│ │ ├── path.ts
│ │ ├── player.ts
│ │ ├── playlist.ts
│ │ ├── playlists.ts
│ │ ├── queue.ts
│ │ ├── range.js
│ │ ├── search.ts
│ │ ├── settings.ts
│ │ ├── song.ts
│ │ ├── tag.ts
│ │ ├── yt-result.ts
│ │ └── yt.ts
│ ├── format.ts
│ ├── html.ts
│ ├── icons.ts
│ ├── item.ts
│ ├── mpd-mock.js
│ ├── mpd.ts
│ ├── parser.ts
│ └── selection.ts
├── svg2js.sh
└── tsconfig.json
├── index.js
├── misc
├── cyp.service.template
├── screen1.png
└── screen2.png
└── package.json
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Create and publish a Docker image
2 |
3 | on:
4 | push:
5 | branches: ['master']
6 |
7 | env:
8 | REGISTRY: ghcr.io
9 | IMAGE_NAME: ${{ github.repository }}
10 |
11 | jobs:
12 | build-and-push-image:
13 | runs-on: ubuntu-latest
14 | permissions:
15 | contents: read
16 | packages: write
17 |
18 | steps:
19 | - name: Checkout repository
20 | uses: actions/checkout@v3
21 |
22 | - name: Log in to the Container registry
23 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
24 | with:
25 | registry: ${{ env.REGISTRY }}
26 | username: ${{ github.actor }}
27 | password: ${{ secrets.GITHUB_TOKEN }}
28 |
29 | - name: Extract metadata (tags, labels) for Docker
30 | id: meta
31 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
32 | with:
33 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
34 |
35 | - name: Build and push Docker image
36 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
37 | with:
38 | context: .
39 | push: true
40 | tags: ${{ steps.meta.outputs.tags }}
41 | labels: ${{ steps.meta.outputs.labels }}
42 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | _youtube/*
3 | !_youtube/.empty
4 | cyp.service
5 | passwords.json
6 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16
2 | RUN apt update && apt install -y jq && apt clean
3 | RUN curl -L https://yt-dl.org/downloads/latest/youtube-dl -o /usr/local/bin/youtube-dl \
4 | && chmod a+rx /usr/local/bin/youtube-dl
5 | WORKDIR /cyp
6 | COPY package.json .
7 | RUN npm i
8 | COPY index.js .
9 | COPY app ./app
10 | EXPOSE 8080
11 | ENTRYPOINT ["node", "."]
12 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Ondřej Žára
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | LESS := npm exec -- lessc
2 | ESBUILD := npm exec -- esbuild
3 | APP := app
4 | CSS := $(APP)/cyp.css
5 | JS := $(APP)/cyp.js
6 | ICONS := $(APP)/js/icons.ts
7 | SYSD_USER := ~/.config/systemd/user
8 | SERVICE := cyp.service
9 |
10 | all: $(CSS) $(JS)
11 |
12 | icons: $(ICONS)
13 |
14 | $(ICONS): $(APP)/icons/*
15 | $(APP)/svg2js.sh $(APP)/icons > $@
16 |
17 | $(JS): $(APP)/js/* $(APP)/js/elements/*
18 | $(ESBUILD) --bundle --target=es2017 $(APP)/js/cyp.ts > $@
19 |
20 | $(CSS): $(APP)/css/* $(APP)/css/elements/*
21 | $(LESS) -x $(APP)/css/cyp.less > $@
22 |
23 | service: $(SERVICE)
24 | systemctl --user enable $(PWD)/$(SERVICE)
25 |
26 | $(SERVICE): misc/cyp.service.template
27 | cat $^ | envsubst > $@
28 |
29 | watch: all
30 | while inotifywait -e MODIFY -r $(APP)/css $(APP)/js ; do make $^ ; done
31 |
32 | clean:
33 | rm -f $(SERVICE) $(CSS) $(JS)
34 |
35 | docker-image:
36 | docker build -t cyp .
37 |
38 | docker-run:
39 | docker run --network=host -v "$$(pwd)"/_youtube:/cyp/_youtube cyp
40 |
41 | .PHONY: all watch icons service clean
42 |
43 | .DELETE_ON_ERROR:
44 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CYP: Control Your Player
2 |
3 | CYP is a web-based frontend for [MPD](https://www.musicpd.org/), the Music Player Daemon. You can use it to control the playback without having to install native application(s). It works in modern web browsers, both desktop and mobile.
4 |
5 | ## Screenshots
6 |
7 |  
8 |
9 |
10 | ## Features
11 | - Control the playback, queue, volume
12 | - Save and load playlists
13 | - Browse the library by artists/albums/directories
14 | - Display album art via native MPD calls (no need to access the library; requires MPD >= 0.21)
15 | - [Youtube-dl](https://ytdl-org.github.io/youtube-dl/index.html) integration
16 | - Dark/Light themes
17 |
18 |
19 | ## Installation
20 |
21 | Make sure you have a working MPD setup first and Node version >= 10
22 |
23 | ```sh
24 | git clone https://github.com/ondras/cyp.git && cd cyp
25 | npm i
26 | node .
27 | ```
28 |
29 | Point your browser to http://localhost:8080 to open the interface. Specifying a custom MPD address can be done:
30 | 1. using `MPD_HOST` and `MPD_PORT` environment variables, or
31 | 1. via a `server` querystring argument (`?server=localhost:6655`).
32 |
33 | ## Instalation - Docker
34 |
35 | Alternatively, you can use Docker to run CYP.
36 |
37 | ```sh
38 | git clone https://github.com/ondras/cyp.git && cd cyp
39 | docker build -t cyp .
40 | docker run --network=host cyp
41 | ```
42 |
43 | ## Installation - Apache ProxyPass
44 |
45 | If you want to run CYP as a service and proxy it through Apache2, you will need to enable several modules.
46 |
47 | # a2enmod proxy
48 | # a2enmod proxy_http
49 | # a2enmod proxy_wstunnel
50 | # a2enmod proxypass
51 |
52 |
53 | To present CYP in a virutal folder named "music" (https://example.com/music/) add the following to your site config.
54 |
55 |
56 | # MPD daemon
57 | RewriteEngine on # Enable the RewriteEngine
58 | RewriteCond %{REQUEST_FILENAME} !-f # If the requested file isn't a file
59 | RewriteCond %{REQUEST_FILENAME} !-d # And if it isn't a directory
60 | RewriteCond %{REQUEST_URI} .*/music$ # And if they only requested /music instead of /music/
61 | RewriteRule ^(.+[^/])$ %{REQUEST_URI}/ [QSA,L,R=301] # Then append a trailing slash
62 |
63 | ProxyPass /music/ http://localhost:3366/ # Proxy all request to /music/ to the CYP server (running on the same server as apache)
64 | ProxyWebsocketFallbackToProxyHttp Off # Don't fallback to http for WebSocket requests
65 |
66 | # Rewrite WebSocket requests to CYP WebSocket requets, (also converts wss to ws)
67 | RewriteEngine on
68 | RewriteCond %{HTTP:Upgrade} websocket [NC]
69 | RewriteCond %{HTTP:Connection} upgrade [NC]
70 | RewriteRule ^/music/?(.*) "ws://localhost:3366/$1" [P,L]
71 |
72 | ## Installation - nginx
73 |
74 | location /music/ {
75 | proxy_pass_header Set-Cookie;
76 | proxy_set_header Host $host;
77 | proxy_set_header X-Real-IP $remote_addr;
78 | proxy_set_header X-Forwarded-Proto $scheme;
79 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
80 | proxy_http_version 1.1;
81 | proxy_set_header Upgrade $http_upgrade;
82 | proxy_set_header Connection "Upgrade";
83 | proxy_set_header Host $host;
84 | proxy_pass http://localhost:8080/;
85 | }
86 |
87 |
88 |
89 | ## Youtube-dl integration
90 |
91 | 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).
92 |
93 | If you use Docker, you need to mount the `_youtube` directory into the image:
94 |
95 | ```sh
96 | docker run --network=host -v "$(pwd)"/_youtube:/cyp/_youtube cyp
97 | ```
98 |
99 |
100 | ## Changing the port
101 |
102 | ...is done via the `PORT` environment variable. If you use Docker, the `-e` switch does the trick:
103 |
104 | ```sh
105 | docker run --network=host -e PORT=12345 cyp
106 | ```
107 |
108 | ## Password-protected MPD
109 |
110 | Create a `passwords.json` file in CYPs home directory. Specify passwords for available MPD servers:
111 |
112 | ```json
113 | {
114 | "localhost:6600": "my-pass-1",
115 | "some.other.server.or.ip:12345": "my-pass-2"
116 | }
117 | ```
118 |
119 | Make sure that hostnames and ports match those specified via the `server` querystring argument (defaults to `localhost:6600`).
120 |
121 | ## Technology
122 | - TypeScript
123 | - Connected to MPD via WebSockets (using the [ws2mpd](https://github.com/ondras/ws2mpd/) bridge)
124 | - Token-based access to the WebSocket endpoint (better than an `Origin` check)
125 | - Written using [*Custom Elements*](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements)
126 | - Responsive layout via Flexbox
127 | - CSS Custom Properties
128 | - SVG icons (Material Design)
129 | - Can spawn Youtube-dl to search/download audio files
130 | - Album art retrieved directly from MPD (and cached via localStorage)
131 |
--------------------------------------------------------------------------------
/_youtube/.empty:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ondras/cyp/abb70c825477585bd3ae5a82158d1c139f5aefbf/_youtube/.empty
--------------------------------------------------------------------------------
/app/css/art.less:
--------------------------------------------------------------------------------
1 | .art {
2 | flex: none;
3 | .icon, img {
4 | display: block;
5 | width: 100%;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/app/css/cyp.less:
--------------------------------------------------------------------------------
1 | *, *::before, *::after { box-sizing: inherit; }
2 |
3 | html {
4 | background-color: var(--fg);
5 | }
6 |
7 | body {
8 | margin: 0;
9 | }
10 |
11 | main {
12 | flex: auto;
13 | overflow: auto;
14 | }
15 |
16 | header, footer {
17 | flex: none;
18 | z-index: 1;
19 | box-shadow: var(--box-shadow);
20 | }
21 |
22 | footer {
23 | position: relative; // kotva pro cyp-commands
24 | overflow: hidden; // vyjizdeci cyp-commands
25 | height: 56px;
26 | @media (max-width: @breakpoint-menu) {
27 | height: 40px;
28 | }
29 | }
30 |
31 | input, select {
32 | font: inherit;
33 | }
34 |
35 | select {
36 | color: inherit;
37 | }
38 |
39 | option {
40 | color: initial;
41 | }
42 |
43 | button {
44 | color: inherit;
45 | font: inherit;
46 | -webkit-appearance: none;
47 | -moz-appearance: none;
48 | appearance: none;
49 |
50 | &:not([hidden]) { display: flex; }
51 | flex-direction: row;
52 | align-items: center;
53 |
54 | flex: none;
55 |
56 | background-color: transparent;
57 | padding: 0;
58 | border: none;
59 | line-height: 1;
60 | cursor: pointer;
61 | }
62 |
63 | select {
64 | background-color: transparent;
65 | border: 1px solid var(--fg);
66 | border-radius: 4px;
67 | padding: 2px 4px;
68 | }
69 |
70 | @import "font.less";
71 | @import "icons.less";
72 | @import "mixins.less";
73 | @import "art.less";
74 | @import "variables.less";
75 |
76 | @import "elements/app.less";
77 | @import "elements/menu.less";
78 | @import "elements/song.less";
79 | @import "elements/player.less";
80 | @import "elements/playlists.less";
81 | @import "elements/queue.less";
82 | @import "elements/settings.less";
83 | @import "elements/yt.less";
84 | @import "elements/range.less";
85 | @import "elements/playlist.less";
86 | @import "elements/search.less";
87 | @import "elements/filter.less";
88 | @import "elements/library.less";
89 | @import "elements/tag.less";
90 | @import "elements/back.less";
91 | @import "elements/path.less";
92 | @import "elements/yt-result.less";
93 |
--------------------------------------------------------------------------------
/app/css/elements/app.less:
--------------------------------------------------------------------------------
1 | cyp-app {
2 | .flex-column;
3 |
4 | box-sizing: border-box;
5 | margin: 0 auto;
6 | max-width: 800px;
7 | height: 100vh;
8 |
9 | font-family: lato, sans-serif;
10 | font-size: 16px;
11 | line-height: 1.25;
12 | background-color: var(--bg);
13 | color: var(--fg);
14 |
15 | white-space: nowrap;
16 | }
17 |
18 | @supports (height: 100dvh) {
19 | cyp-app {
20 | height: 100dvh;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/css/elements/back.less:
--------------------------------------------------------------------------------
1 | cyp-back {
2 | .item;
3 | }
4 |
--------------------------------------------------------------------------------
/app/css/elements/filter.less:
--------------------------------------------------------------------------------
1 | cyp-filter {
2 | .item;
3 |
4 | .icon {
5 | width: 32px;
6 | margin-left: var(--icon-spacing);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/app/css/elements/library.less:
--------------------------------------------------------------------------------
1 | cyp-library {
2 | nav {
3 | .flex-column;
4 | align-items: center;
5 |
6 | button {
7 | .font-large;
8 | width: 200px;
9 | margin-top: 2em;
10 | text-decoration: underline;
11 |
12 | .icon {
13 | width: 32px;
14 | margin-right: var(--icon-spacing);
15 | }
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/app/css/elements/menu.less:
--------------------------------------------------------------------------------
1 | cyp-menu, cyp-commands {
2 | .flex-row;
3 | height: 100%;
4 |
5 | button {
6 | height: 100%;
7 |
8 | .flex-column;
9 | align-items: center;
10 | justify-content: center;
11 |
12 | @media (max-width: @breakpoint-menu) {
13 | flex-direction: row;
14 | span:not([id]) { display: none; }
15 | }
16 |
17 | .icon + * { margin-top: 2px; }
18 | }
19 | }
20 |
21 | cyp-menu button {
22 | flex: 1 0 0;
23 | border-top: var(--border-width) solid transparent;
24 | border-bottom: var(--border-width) solid transparent;
25 |
26 | .icon {
27 | margin-right: var(--icon-spacing);
28 | }
29 |
30 | &.active {
31 | border-top-color: var(--primary);
32 | color: var(--primary);
33 | background-color: var(--primary-tint);
34 | }
35 | }
36 |
37 | cyp-commands {
38 | position: absolute;
39 | left: 0;
40 | top: 0;
41 | width: 100%;
42 | transition: top 300ms;
43 |
44 | background-color: var(--bg);
45 |
46 | &[hidden] {
47 | display: flex;
48 | top: 100%;
49 | }
50 |
51 | button {
52 | flex: 0 1 @breakpoint-menu/6;
53 | &.last {
54 | order: 1;
55 | margin-left: auto;
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/app/css/elements/path.less:
--------------------------------------------------------------------------------
1 | cyp-path {
2 | .item;
3 | }
4 |
--------------------------------------------------------------------------------
/app/css/elements/player.less:
--------------------------------------------------------------------------------
1 | cyp-player {
2 | @art-size: 96px;
3 |
4 | .flex-row;
5 | align-items: stretch;
6 |
7 | &:not([data-state=play]) .pause { display: none; }
8 | &[data-state=play] .play { display: none; }
9 | &:not([data-flags~=random]) .random, &:not([data-flags~=repeat]) .repeat { opacity: 0.5; }
10 | &[data-flags~=mute] .mute .icon-volume-high { display: none; }
11 | &:not([data-flags~=mute]) .mute .icon-volume-off { display: none; }
12 |
13 | x-range {
14 | flex: auto;
15 | --elapsed-color: var(--primary);
16 | }
17 |
18 | .art {
19 | width: @art-size;
20 | height: @art-size;
21 | }
22 |
23 | .info {
24 | flex: auto;
25 | min-width: 0;
26 |
27 | .flex-column;
28 | justify-content: space-between;
29 |
30 | padding: 0 var(--icon-spacing);
31 |
32 | .title {
33 | margin-top: 8px;
34 | .font-large;
35 | font-weight: bold;
36 | }
37 | .title, .subtitle { .ellipsis; }
38 | }
39 |
40 | .timeline {
41 | flex: none;
42 | height: var(--icon-size);
43 | margin-bottom: 4px;
44 | .flex-row;
45 |
46 | .duration, .elapsed {
47 | flex: none;
48 | width: 5ch;
49 | text-align: center;
50 | }
51 | }
52 |
53 | .controls {
54 | width: 220px;
55 | min-width: 0;
56 |
57 | .flex-column;
58 |
59 | .playback {
60 | flex: auto;
61 |
62 | .flex-row;
63 | justify-content: space-around;
64 |
65 | .icon { width: 40px; }
66 | .icon-play, .icon-pause { width: 64px; }
67 | }
68 |
69 | .volume {
70 | flex: none;
71 | margin-bottom: 4px;
72 |
73 | .flex-row;
74 | .mute { margin-right: var(--icon-spacing); }
75 | }
76 | }
77 |
78 | .misc {
79 | flex: none;
80 |
81 | .flex-column;
82 | justify-content: space-around;
83 |
84 | .icon { width: 32px; }
85 | }
86 |
87 | @media (max-width: 519px) {
88 | flex-wrap: wrap;
89 | justify-content: space-between;
90 |
91 | .info {
92 | order: 1;
93 | flex-basis: 100%;
94 | height: @art-size;
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/app/css/elements/playlist.less:
--------------------------------------------------------------------------------
1 | cyp-playlist {
2 | .item;
3 | }
4 |
--------------------------------------------------------------------------------
/app/css/elements/playlists.less:
--------------------------------------------------------------------------------
1 | cyp-playlists {
2 | }
3 |
--------------------------------------------------------------------------------
/app/css/elements/queue.less:
--------------------------------------------------------------------------------
1 | cyp-queue {
2 | .current {
3 | > .icon { color: var(--primary); }
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/app/css/elements/range.less:
--------------------------------------------------------------------------------
1 | ../../../node_modules/custom-range/range.less
--------------------------------------------------------------------------------
/app/css/elements/search.less:
--------------------------------------------------------------------------------
1 | cyp-search {
2 | form {
3 | .item;
4 | align-items: stretch;
5 |
6 | button:first-of-type { // pseudo-class to override
7 | margin-left: var(--icon-spacing);
8 | }
9 | }
10 |
11 | &.pending form {
12 | background-image: linear-gradient(var(--primary), var(--primary));
13 | background-repeat: no-repeat;
14 | background-size: 25% var(--border-width);
15 | animation: bar ease-in-out 3s alternate infinite;
16 | }
17 | }
18 |
19 |
20 | @keyframes bar {
21 | 0% { background-position: 0 100%; }
22 | 100% { background-position: 100% 100%; }
23 | }
24 |
--------------------------------------------------------------------------------
/app/css/elements/settings.less:
--------------------------------------------------------------------------------
1 | cyp-settings {
2 | --spacing: 8px;
3 |
4 | .font-large;
5 |
6 | dl {
7 | margin: var(--spacing);
8 | display: grid;
9 | grid-template-columns: max-content 1fr;
10 | align-items: center;
11 | grid-gap: var(--spacing);
12 | }
13 |
14 | dt {
15 | font-weight: bold;
16 | }
17 |
18 | dd {
19 | margin: 0;
20 | .flex-column;
21 | align-items: start;
22 | }
23 |
24 | label {
25 | .flex-row;
26 |
27 | [type=radio], [type=checkbox] {
28 | margin: 0 4px 0 0;
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/app/css/elements/song.less:
--------------------------------------------------------------------------------
1 | cyp-song {
2 | .item;
3 |
4 | .multiline {
5 | .flex-column;
6 | min-width: 0; // bez tohoto se odmita zmensit
7 |
8 | .subtitle { .ellipsis; }
9 | }
10 |
11 | cyp-queue & {
12 | > .icon {
13 | width: 32px;
14 | margin-right: 8px;
15 | }
16 | .track { display: none; }
17 |
18 | &:not(.playing) > .icon-play, &.playing > .icon-music {
19 | display: none;
20 | }
21 |
22 | &.playing {
23 | > .icon { color: var(--primary) }
24 |
25 | &::after {
26 | content: "";
27 | position: absolute;
28 | left: 0;
29 | bottom: 0;
30 | background-color: var(--primary);
31 | width: calc(100% * var(--progress, 0));
32 | height: var(--border-width);
33 | }
34 | }
35 | }
36 |
37 | cyp-library &, cyp-playlists & {
38 | padding-left: 0;
39 | padding-top: 0;
40 | padding-bottom: 0;
41 |
42 | > .icon { width: 64px;}
43 | > .icon-play { display: none; }
44 | }
45 | }
46 |
47 |
--------------------------------------------------------------------------------
/app/css/elements/tag.less:
--------------------------------------------------------------------------------
1 | cyp-tag {
2 | .item;
3 |
4 | padding-left: 0;
5 | padding-top: 0;
6 | padding-bottom: 0;
7 |
8 | .art {
9 | margin-right: var(--icon-spacing);
10 | width: 64px;
11 | height: 64px;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/app/css/elements/yt-result.less:
--------------------------------------------------------------------------------
1 | cyp-yt-result {
2 | .item;
3 | cursor: default;
4 |
5 | button .icon {
6 | width: var(--icon-size);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/app/css/elements/yt.less:
--------------------------------------------------------------------------------
1 | cyp-yt {
2 | pre {
3 | margin: 0.5em 0.5ch;
4 | flex-grow: 1;
5 | overflow: auto;
6 | white-space: pre-wrap;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/app/css/font.less:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: "Lato";
3 | src: url("font/LatoLatin-Regular.woff2") format("woff2");
4 | font-style: normal;
5 | font-weight: normal;
6 | }
7 |
8 | @font-face {
9 | font-family: "Lato";
10 | src: url("font/LatoLatin-Bold.woff2") format("woff2");
11 | font-style: normal;
12 | font-weight: bold;
13 | }
14 |
--------------------------------------------------------------------------------
/app/css/icons.less:
--------------------------------------------------------------------------------
1 | .icon {
2 | width: var(--icon-size);
3 | flex: none;
4 |
5 | path, polygon, circle {
6 | &:not([fill]) {
7 | fill: currentColor;
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/app/css/mixins.less:
--------------------------------------------------------------------------------
1 | .flex-row {
2 | &:not([hidden]) { display: flex; }
3 | flex-direction: row;
4 | align-items: center;
5 | }
6 |
7 | .flex-column {
8 | &:not([hidden]) { display: flex; }
9 | flex-direction: column;
10 | }
11 |
12 | .ellipsis {
13 | overflow: hidden;
14 | text-overflow: ellipsis;
15 | }
16 |
17 | .font-large {
18 | font-size: 18px;
19 | line-height: 24px;
20 | }
21 |
22 | .selectable {
23 | cursor: pointer;
24 | position: relative; // kotva pro selected::before
25 |
26 | &.selected {
27 | color: var(--primary);
28 | background-color: var(--primary-tint);
29 |
30 | &::before {
31 | content: "";
32 | position: absolute;
33 | left: 0;
34 | top: 0;
35 | bottom: 0;
36 | width: var(--border-width);
37 | background-color: var(--primary);
38 | }
39 | }
40 | }
41 |
42 | .item {
43 | .flex-row;
44 |
45 | &:nth-child(odd) {
46 | background-color: var(--bg-alt);
47 | }
48 |
49 | .selectable; // az po nth-child, aby byl vyber pozdeji
50 |
51 | padding: 8px;
52 |
53 | > .icon {
54 | margin-right: var(--icon-spacing);
55 | }
56 |
57 | .title {
58 | .font-large;
59 | .ellipsis;
60 |
61 | font-weight: bold;
62 | min-width: 0;
63 | }
64 |
65 | button {
66 | &:first-of-type { margin-left: auto; }
67 | .icon { width: 32px; }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/app/css/variables.less:
--------------------------------------------------------------------------------
1 | @breakpoint-menu: 480px;
2 |
3 | cyp-app {
4 | --icon-size: 24px;
5 | --icon-spacing: 4px;
6 | --primary: rgb(var(--primary-raw));
7 | --primary-tint: rgba(var(--primary-raw), 0.1);
8 | --box-shadow: 0 0 3px #000;
9 | --border-width: 4px;
10 | }
11 |
12 | .light() {
13 | --fg: #333;
14 | --bg: #f0f0f0;
15 | --bg-alt: #e0e0e0;
16 | --text-shadow: none;
17 | }
18 |
19 | .dark() {
20 | --fg: #f0f0f0;
21 | --bg: #333;
22 | --bg-alt: #444;
23 | --text-shadow: 0 1px 1px rgba(0, 0, 0, 0.8);
24 | }
25 |
26 | cyp-app[theme=light] { .light(); }
27 | cyp-app[theme=dark] { .dark(); }
28 |
29 | @media (prefers-color-scheme: dark) {
30 | cyp-app[theme=auto] { .dark(); }
31 | }
32 |
33 | @media (prefers-color-scheme: light) {
34 | cyp-app[theme=auto] { .light(); }
35 | }
36 |
37 | @media (max-width: 640px), (max-height:640px) {
38 | cyp-app[theme] {
39 | --text-shadow: none;
40 | }
41 | }
42 |
43 | cyp-app[color=dodgerblue] {
44 | --primary-raw: 30, 144, 255;
45 | }
46 |
47 | cyp-app[color=darkorange] {
48 | --primary-raw: 255, 140, 0;
49 | }
50 |
51 | cyp-app[color=limegreen] {
52 | --primary-raw: 50, 205, 50;
53 | }
54 |
--------------------------------------------------------------------------------
/app/cyp.css:
--------------------------------------------------------------------------------
1 | *,*::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;overflow:hidden;height:56px}@media (max-width:480px){footer{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;flex-direction:row;align-items:center;flex:none;background-color:transparent;padding:0;border:none;line-height:1;cursor:pointer}button:not([hidden]){display:flex}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:normal;font-weight:bold}.icon{width:var(--icon-size);flex:none}.icon path:not([fill]),.icon polygon:not([fill]),.icon circle:not([fill]){fill:currentColor}.flex-row{flex-direction:row;align-items:center}.flex-row:not([hidden]){display:flex}.flex-column{flex-direction:column}.flex-column:not([hidden]){display:flex}.ellipsis{overflow:hidden;text-overflow:ellipsis}.font-large{font-size:18px;line-height:24px}.selectable{cursor:pointer;position:relative}.selectable.selected{color:var(--primary);background-color:var(--primary-tint)}.selectable.selected::before{content:"";position:absolute;left:0;top:0;bottom:0;width:var(--border-width);background-color:var(--primary)}.item{flex-direction:row;align-items:center;cursor:pointer;position:relative;padding:8px}.item:not([hidden]){display:flex}.item:nth-child(odd){background-color:var(--bg-alt)}.item.selected{color:var(--primary);background-color:var(--primary-tint)}.item.selected::before{content:"";position:absolute;left:0;top:0;bottom:0;width:var(--border-width);background-color:var(--primary)}.item>.icon{margin-right:var(--icon-spacing)}.item .title{font-size:18px;line-height:24px;overflow:hidden;text-overflow:ellipsis;font-weight:bold;min-width:0}.item button:first-of-type{margin-left:auto}.item button .icon{width:32px}.art{flex:none}.art .icon,.art img{display:block;width:100%}cyp-app{--icon-size:24px;--icon-spacing:4px;--primary:rgb(var(--primary-raw));--primary-tint:rgba(var(--primary-raw), .1);--box-shadow:0 0 3px #000;--border-width:4px}cyp-app[theme=light]{--fg:#333;--bg:#f0f0f0;--bg-alt:#e0e0e0;--text-shadow:none}cyp-app[theme=dark]{--fg:#f0f0f0;--bg:#333;--bg-alt:#444;--text-shadow:0 1px 1px rgba(0,0,0,0.8)}@media (prefers-color-scheme:dark){cyp-app[theme=auto]{--fg:#f0f0f0;--bg:#333;--bg-alt:#444;--text-shadow:0 1px 1px rgba(0,0,0,0.8)}}@media (prefers-color-scheme:light){cyp-app[theme=auto]{--fg:#333;--bg:#f0f0f0;--bg-alt:#e0e0e0;--text-shadow:none}}@media (max-width:640px),(max-height:640px){cyp-app[theme]{--text-shadow:none}}cyp-app[color=dodgerblue]{--primary-raw:30, 144, 255}cyp-app[color=darkorange]{--primary-raw:255, 140, 0}cyp-app[color=limegreen]{--primary-raw:50, 205, 50}cyp-app{flex-direction:column;box-sizing:border-box;margin:0 auto;max-width:800px;height:100vh;font-family:lato,sans-serif;font-size:16px;line-height:1.25;background-color:var(--bg);color:var(--fg);white-space:nowrap}cyp-app:not([hidden]){display:flex}@supports (height: 100dvh){cyp-app{height:100dvh}}cyp-menu,cyp-commands{flex-direction:row;align-items:center;height:100%}cyp-menu:not([hidden]),cyp-commands:not([hidden]){display:flex}cyp-menu button,cyp-commands button{height:100%;flex-direction:column;align-items:center;justify-content:center}cyp-menu button:not([hidden]),cyp-commands button:not([hidden]){display:flex}@media (max-width:480px){cyp-menu button,cyp-commands button{flex-direction:row}cyp-menu button span:not([id]),cyp-commands button span:not([id]){display:none}}cyp-menu button .icon+*,cyp-commands button .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}cyp-menu button .icon{margin-right:var(--icon-spacing)}cyp-menu button.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)}cyp-commands[hidden]{display:flex;top:100%}cyp-commands button{flex:0 1 80px}cyp-commands button.last{order:1;margin-left:auto}cyp-song{flex-direction:row;align-items:center;cursor:pointer;position:relative;padding:8px}cyp-song:not([hidden]){display:flex}cyp-song:nth-child(odd){background-color:var(--bg-alt)}cyp-song.selected{color:var(--primary);background-color:var(--primary-tint)}cyp-song.selected::before{content:"";position:absolute;left:0;top:0;bottom:0;width:var(--border-width);background-color:var(--primary)}cyp-song>.icon{margin-right:var(--icon-spacing)}cyp-song .title{font-size:18px;line-height:24px;overflow:hidden;text-overflow:ellipsis;font-weight:bold;min-width:0}cyp-song button:first-of-type{margin-left:auto}cyp-song button .icon{width:32px}cyp-song .multiline{flex-direction:column;min-width:0}cyp-song .multiline:not([hidden]){display:flex}cyp-song .multiline .subtitle{overflow:hidden;text-overflow:ellipsis}cyp-queue cyp-song>.icon{width:32px;margin-right:8px}cyp-queue cyp-song .track{display:none}cyp-queue cyp-song:not(.playing)>.icon-play,cyp-queue cyp-song.playing>.icon-music{display:none}cyp-queue cyp-song.playing>.icon{color:var(--primary)}cyp-queue cyp-song.playing::after{content:"";position:absolute;left:0;bottom:0;background-color:var(--primary);width:calc(100% * var(--progress, 0));height:var(--border-width)}cyp-library cyp-song,cyp-playlists cyp-song{padding-left:0;padding-top:0;padding-bottom:0}cyp-library cyp-song>.icon,cyp-playlists cyp-song>.icon{width:64px}cyp-library cyp-song>.icon-play,cyp-playlists cyp-song>.icon-play{display:none}cyp-player{flex-direction:row;align-items:center;align-items:stretch}cyp-player:not([hidden]){display:flex}cyp-player:not([data-state=play]) .pause{display:none}cyp-player[data-state=play] .play{display:none}cyp-player:not([data-flags~=random]) .random,cyp-player:not([data-flags~=repeat]) .repeat{opacity:.5}cyp-player[data-flags~=mute] .mute .icon-volume-high{display:none}cyp-player:not([data-flags~=mute]) .mute .icon-volume-off{display:none}cyp-player x-range{flex:auto;--elapsed-color:var(--primary)}cyp-player .art{width:96px;height:96px}cyp-player .info{flex:auto;min-width:0;flex-direction:column;justify-content:space-between;padding:0 var(--icon-spacing)}cyp-player .info:not([hidden]){display:flex}cyp-player .info .title{margin-top:8px;font-size:18px;line-height:24px;font-weight:bold}cyp-player .info .title,cyp-player .info .subtitle{overflow:hidden;text-overflow:ellipsis}cyp-player .timeline{flex:none;height:var(--icon-size);margin-bottom:4px;flex-direction:row;align-items:center}cyp-player .timeline:not([hidden]){display:flex}cyp-player .timeline .duration,cyp-player .timeline .elapsed{flex:none;width:5ch;text-align:center}cyp-player .controls{width:220px;min-width:0;flex-direction:column}cyp-player .controls:not([hidden]){display:flex}cyp-player .controls .playback{flex:auto;flex-direction:row;align-items:center;justify-content:space-around}cyp-player .controls .playback:not([hidden]){display:flex}cyp-player .controls .playback .icon{width:40px}cyp-player .controls .playback .icon-play,cyp-player .controls .playback .icon-pause{width:64px}cyp-player .controls .volume{flex:none;margin-bottom:4px;flex-direction:row;align-items:center}cyp-player .controls .volume:not([hidden]){display:flex}cyp-player .controls .volume .mute{margin-right:var(--icon-spacing)}cyp-player .misc{flex:none;flex-direction:column;justify-content:space-around}cyp-player .misc:not([hidden]){display:flex}cyp-player .misc .icon{width:32px}@media (max-width:519px){cyp-player{flex-wrap:wrap;justify-content:space-between}cyp-player .info{order:1;flex-basis:100%;height:96px}}cyp-queue .current>.icon{color:var(--primary)}cyp-settings{--spacing:8px;font-size:18px;line-height:24px}cyp-settings dl{margin:var(--spacing);display:grid;grid-template-columns:max-content 1fr;align-items:center;grid-gap:var(--spacing)}cyp-settings dt{font-weight:bold}cyp-settings dd{margin:0;flex-direction:column;align-items:start}cyp-settings dd:not([hidden]){display:flex}cyp-settings label{flex-direction:row;align-items:center}cyp-settings label:not([hidden]){display:flex}cyp-settings label [type=radio],cyp-settings label [type=checkbox]{margin:0 4px 0 0}cyp-yt pre{margin:.5em .5ch;flex-grow:1;overflow:auto;white-space:pre-wrap}x-range{--thumb-size:8px;--thumb-color:#ddd;--thumb-shadow:#000;--thumb-hover-color:#fff;--track-size:4px;--track-color:#888;--track-shadow:#000;--elapsed-color:#ddd;--remaining-color:transparent;--radius:calc(var(--track-size)/2);display:inline-block;position:relative;width:192px;height:16px}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:var(--track-color);box-shadow:0 0 1px var(--thumb-shadow)}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:.5}x-range:not([disabled]) .-thumb:hover{background-color:var(--thumb-hover-color)}cyp-playlist{flex-direction:row;align-items:center;cursor:pointer;position:relative;padding:8px}cyp-playlist:not([hidden]){display:flex}cyp-playlist:nth-child(odd){background-color:var(--bg-alt)}cyp-playlist.selected{color:var(--primary);background-color:var(--primary-tint)}cyp-playlist.selected::before{content:"";position:absolute;left:0;top:0;bottom:0;width:var(--border-width);background-color:var(--primary)}cyp-playlist>.icon{margin-right:var(--icon-spacing)}cyp-playlist .title{font-size:18px;line-height:24px;overflow:hidden;text-overflow:ellipsis;font-weight:bold;min-width:0}cyp-playlist button:first-of-type{margin-left:auto}cyp-playlist button .icon{width:32px}cyp-search form{flex-direction:row;align-items:center;cursor:pointer;position:relative;padding:8px;align-items:stretch}cyp-search form:not([hidden]){display:flex}cyp-search form:nth-child(odd){background-color:var(--bg-alt)}cyp-search form.selected{color:var(--primary);background-color:var(--primary-tint)}cyp-search form.selected::before{content:"";position:absolute;left:0;top:0;bottom:0;width:var(--border-width);background-color:var(--primary)}cyp-search form>.icon{margin-right:var(--icon-spacing)}cyp-search form .title{font-size:18px;line-height:24px;overflow:hidden;text-overflow:ellipsis;font-weight:bold;min-width:0}cyp-search form button:first-of-type{margin-left:auto}cyp-search form button .icon{width:32px}cyp-search form button:first-of-type{margin-left:var(--icon-spacing)}cyp-search.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%}}cyp-filter{flex-direction:row;align-items:center;cursor:pointer;position:relative;padding:8px}cyp-filter:not([hidden]){display:flex}cyp-filter:nth-child(odd){background-color:var(--bg-alt)}cyp-filter.selected{color:var(--primary);background-color:var(--primary-tint)}cyp-filter.selected::before{content:"";position:absolute;left:0;top:0;bottom:0;width:var(--border-width);background-color:var(--primary)}cyp-filter>.icon{margin-right:var(--icon-spacing)}cyp-filter .title{font-size:18px;line-height:24px;overflow:hidden;text-overflow:ellipsis;font-weight:bold;min-width:0}cyp-filter button:first-of-type{margin-left:auto}cyp-filter button .icon{width:32px}cyp-filter .icon{width:32px;margin-left:var(--icon-spacing)}cyp-library nav{flex-direction:column;align-items:center}cyp-library nav:not([hidden]){display:flex}cyp-library nav button{font-size:18px;line-height:24px;width:200px;margin-top:2em;text-decoration:underline}cyp-library nav button .icon{width:32px;margin-right:var(--icon-spacing)}cyp-tag{flex-direction:row;align-items:center;cursor:pointer;position:relative;padding:8px;padding-left:0;padding-top:0;padding-bottom:0}cyp-tag:not([hidden]){display:flex}cyp-tag:nth-child(odd){background-color:var(--bg-alt)}cyp-tag.selected{color:var(--primary);background-color:var(--primary-tint)}cyp-tag.selected::before{content:"";position:absolute;left:0;top:0;bottom:0;width:var(--border-width);background-color:var(--primary)}cyp-tag>.icon{margin-right:var(--icon-spacing)}cyp-tag .title{font-size:18px;line-height:24px;overflow:hidden;text-overflow:ellipsis;font-weight:bold;min-width:0}cyp-tag button:first-of-type{margin-left:auto}cyp-tag button .icon{width:32px}cyp-tag .art{margin-right:var(--icon-spacing);width:64px;height:64px}cyp-back{flex-direction:row;align-items:center;cursor:pointer;position:relative;padding:8px}cyp-back:not([hidden]){display:flex}cyp-back:nth-child(odd){background-color:var(--bg-alt)}cyp-back.selected{color:var(--primary);background-color:var(--primary-tint)}cyp-back.selected::before{content:"";position:absolute;left:0;top:0;bottom:0;width:var(--border-width);background-color:var(--primary)}cyp-back>.icon{margin-right:var(--icon-spacing)}cyp-back .title{font-size:18px;line-height:24px;overflow:hidden;text-overflow:ellipsis;font-weight:bold;min-width:0}cyp-back button:first-of-type{margin-left:auto}cyp-back button .icon{width:32px}cyp-path{flex-direction:row;align-items:center;cursor:pointer;position:relative;padding:8px}cyp-path:not([hidden]){display:flex}cyp-path:nth-child(odd){background-color:var(--bg-alt)}cyp-path.selected{color:var(--primary);background-color:var(--primary-tint)}cyp-path.selected::before{content:"";position:absolute;left:0;top:0;bottom:0;width:var(--border-width);background-color:var(--primary)}cyp-path>.icon{margin-right:var(--icon-spacing)}cyp-path .title{font-size:18px;line-height:24px;overflow:hidden;text-overflow:ellipsis;font-weight:bold;min-width:0}cyp-path button:first-of-type{margin-left:auto}cyp-path button .icon{width:32px}cyp-yt-result{flex-direction:row;align-items:center;cursor:pointer;position:relative;padding:8px;cursor:default}cyp-yt-result:not([hidden]){display:flex}cyp-yt-result:nth-child(odd){background-color:var(--bg-alt)}cyp-yt-result.selected{color:var(--primary);background-color:var(--primary-tint)}cyp-yt-result.selected::before{content:"";position:absolute;left:0;top:0;bottom:0;width:var(--border-width);background-color:var(--primary)}cyp-yt-result>.icon{margin-right:var(--icon-spacing)}cyp-yt-result .title{font-size:18px;line-height:24px;overflow:hidden;text-overflow:ellipsis;font-weight:bold;min-width:0}cyp-yt-result button:first-of-type{margin-left:auto}cyp-yt-result button .icon{width:32px}cyp-yt-result button .icon{width:var(--icon-size)}
--------------------------------------------------------------------------------
/app/cyp.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | (() => {
3 | // node_modules/custom-range/range.js
4 | var Range = class extends HTMLElement {
5 | static get observedAttributes() {
6 | return ["min", "max", "value", "step", "disabled"];
7 | }
8 | constructor() {
9 | super();
10 | this._dom = {};
11 | this.addEventListener("mousedown", this);
12 | this.addEventListener("keydown", this);
13 | }
14 | get _valueAsNumber() {
15 | let raw = this.hasAttribute("value") ? Number(this.getAttribute("value")) : 50;
16 | return this._constrain(raw);
17 | }
18 | get _minAsNumber() {
19 | return this.hasAttribute("min") ? Number(this.getAttribute("min")) : 0;
20 | }
21 | get _maxAsNumber() {
22 | return this.hasAttribute("max") ? Number(this.getAttribute("max")) : 100;
23 | }
24 | get _stepAsNumber() {
25 | return this.hasAttribute("step") ? Number(this.getAttribute("step")) : 1;
26 | }
27 | get value() {
28 | return String(this._valueAsNumber);
29 | }
30 | get valueAsNumber() {
31 | return this._valueAsNumber;
32 | }
33 | get min() {
34 | return this.hasAttribute("min") ? this.getAttribute("min") : "";
35 | }
36 | get max() {
37 | return this.hasAttribute("max") ? this.getAttribute("max") : "";
38 | }
39 | get step() {
40 | return this.hasAttribute("step") ? this.getAttribute("step") : "";
41 | }
42 | get disabled() {
43 | return this.hasAttribute("disabled");
44 | }
45 | set _valueAsNumber(value) {
46 | this.value = String(value);
47 | }
48 | set min(min) {
49 | this.setAttribute("min", min);
50 | }
51 | set max(max) {
52 | this.setAttribute("max", max);
53 | }
54 | set value(value) {
55 | this.setAttribute("value", value);
56 | }
57 | set step(step) {
58 | this.setAttribute("step", step);
59 | }
60 | set disabled(disabled) {
61 | disabled ? this.setAttribute("disabled", "") : this.removeAttribute("disabled");
62 | }
63 | connectedCallback() {
64 | if (this.firstChild) {
65 | return;
66 | }
67 | this.innerHTML = `
68 |
69 |
70 |
71 |
72 |
73 |
74 | `;
75 | Array.from(this.querySelectorAll("[class^='-']")).forEach((node2) => {
76 | let name = node2.className.substring(1);
77 | this._dom[name] = node2;
78 | });
79 | this._update();
80 | }
81 | attributeChangedCallback(name, oldValue, newValue) {
82 | if (!this.firstChild) {
83 | return;
84 | }
85 | switch (name) {
86 | case "min":
87 | case "max":
88 | case "value":
89 | case "step":
90 | this._update();
91 | break;
92 | }
93 | }
94 | handleEvent(e) {
95 | switch (e.type) {
96 | case "mousedown":
97 | if (this.disabled) {
98 | return;
99 | }
100 | document.addEventListener("mousemove", this);
101 | document.addEventListener("mouseup", this);
102 | this._setToMouse(e);
103 | break;
104 | case "mousemove":
105 | this._setToMouse(e);
106 | break;
107 | case "mouseup":
108 | document.removeEventListener("mousemove", this);
109 | document.removeEventListener("mouseup", this);
110 | this.dispatchEvent(new CustomEvent("change"));
111 | break;
112 | case "keydown":
113 | if (this.disabled) {
114 | return;
115 | }
116 | this._handleKey(e.code);
117 | this.dispatchEvent(new CustomEvent("input"));
118 | this.dispatchEvent(new CustomEvent("change"));
119 | break;
120 | }
121 | }
122 | _handleKey(code) {
123 | let min = this._minAsNumber;
124 | let max = this._maxAsNumber;
125 | let range = max - min;
126 | let step = this._stepAsNumber;
127 | switch (code) {
128 | case "ArrowLeft":
129 | case "ArrowDown":
130 | this._valueAsNumber = this._constrain(this._valueAsNumber - step);
131 | break;
132 | case "ArrowRight":
133 | case "ArrowUp":
134 | this._valueAsNumber = this._constrain(this._valueAsNumber + step);
135 | break;
136 | case "Home":
137 | this._valueAsNumber = this._constrain(min);
138 | break;
139 | case "End":
140 | this._valueAsNumber = this._constrain(max);
141 | break;
142 | case "PageUp":
143 | this._valueAsNumber = this._constrain(this._valueAsNumber + range / 10);
144 | break;
145 | case "PageDown":
146 | this._valueAsNumber = this._constrain(this._valueAsNumber - range / 10);
147 | break;
148 | }
149 | }
150 | _constrain(value) {
151 | const min = this._minAsNumber;
152 | const max = this._maxAsNumber;
153 | const step = this._stepAsNumber;
154 | value = Math.max(value, min);
155 | value = Math.min(value, max);
156 | value -= min;
157 | value = Math.round(value / step) * step;
158 | value += min;
159 | if (value > max) {
160 | value -= step;
161 | }
162 | return value;
163 | }
164 | _update() {
165 | let min = this._minAsNumber;
166 | let max = this._maxAsNumber;
167 | let frac = (this._valueAsNumber - min) / (max - min);
168 | this._dom.thumb.style.left = `${frac * 100}%`;
169 | this._dom.remaining.style.left = `${frac * 100}%`;
170 | this._dom.elapsed.style.width = `${frac * 100}%`;
171 | }
172 | _setToMouse(e) {
173 | let rect = this._dom.inner.getBoundingClientRect();
174 | let x = e.clientX;
175 | x = Math.max(x, rect.left);
176 | x = Math.min(x, rect.right);
177 | let min = this._minAsNumber;
178 | let max = this._maxAsNumber;
179 | let frac = (x - rect.left) / (rect.right - rect.left);
180 | let value = this._constrain(min + frac * (max - min));
181 | if (value == this._valueAsNumber) {
182 | return;
183 | }
184 | this._valueAsNumber = value;
185 | this.dispatchEvent(new CustomEvent("input"));
186 | }
187 | };
188 | customElements.define("x-range", Range);
189 |
190 | // app/js/parser.ts
191 | function linesToStruct(lines) {
192 | let result = {};
193 | lines.forEach((line) => {
194 | let cindex = line.indexOf(":");
195 | if (cindex == -1) {
196 | throw new Error(`Malformed line "${line}"`);
197 | }
198 | let key = line.substring(0, cindex);
199 | let value = line.substring(cindex + 2);
200 | if (key in result) {
201 | let old = result[key];
202 | if (old instanceof Array) {
203 | old.push(value);
204 | } else {
205 | result[key] = [old, value];
206 | }
207 | } else {
208 | result[key] = value;
209 | }
210 | });
211 | return result;
212 | }
213 | function songList(lines) {
214 | let songs = [];
215 | let batch = [];
216 | while (lines.length) {
217 | let line = lines[0];
218 | if (line.startsWith("file:") && batch.length) {
219 | let song = linesToStruct(batch);
220 | songs.push(song);
221 | batch = [];
222 | }
223 | batch.push(lines.shift());
224 | }
225 | if (batch.length) {
226 | let song = linesToStruct(batch);
227 | songs.push(song);
228 | }
229 | return songs;
230 | }
231 | function pathContents(lines) {
232 | const prefixes = ["file", "directory", "playlist"];
233 | let batch = [];
234 | let result = {};
235 | let batchPrefix = "";
236 | prefixes.forEach((prefix2) => result[prefix2] = []);
237 | while (lines.length) {
238 | let line = lines[0];
239 | let prefix2 = line.split(":")[0];
240 | if (prefixes.includes(prefix2)) {
241 | if (batch.length) {
242 | result[batchPrefix].push(linesToStruct(batch));
243 | }
244 | batchPrefix = prefix2;
245 | batch = [];
246 | }
247 | batch.push(lines.shift());
248 | }
249 | if (batch.length) {
250 | result[batchPrefix].push(linesToStruct(batch));
251 | }
252 | return result;
253 | }
254 |
255 | // app/js/mpd.ts
256 | var MPD = class {
257 | constructor(ws, initialCommand) {
258 | this.ws = ws;
259 | this.queue = [];
260 | this.canTerminateIdle = false;
261 | this.current = initialCommand;
262 | ws.addEventListener("message", (e) => this._onMessage(e));
263 | ws.addEventListener("close", (e) => this._onClose(e));
264 | }
265 | static async connect() {
266 | let response = await fetch("ticket", { method: "POST" });
267 | let ticket = (await response.json()).ticket;
268 | let ws = new WebSocket(createURL(ticket).href);
269 | return new Promise((resolve, reject) => {
270 | let mpd;
271 | let initialCommand = { resolve: () => resolve(mpd), reject, cmd: "" };
272 | mpd = new this(ws, initialCommand);
273 | });
274 | }
275 | onClose(e) {
276 | }
277 | onChange(changed) {
278 | }
279 | command(cmds) {
280 | let cmd = cmds instanceof Array ? ["command_list_begin", ...cmds, "command_list_end"].join("\n") : cmds;
281 | return new Promise((resolve, reject) => {
282 | this.queue.push({ cmd, resolve, reject });
283 | if (!this.current) {
284 | this.advanceQueue();
285 | } else if (this.canTerminateIdle) {
286 | this.ws.send("noidle");
287 | this.canTerminateIdle = false;
288 | }
289 | });
290 | }
291 | async status() {
292 | let lines = await this.command("status");
293 | return linesToStruct(lines);
294 | }
295 | async currentSong() {
296 | let lines = await this.command("currentsong");
297 | return linesToStruct(lines);
298 | }
299 | async listQueue() {
300 | let lines = await this.command("playlistinfo");
301 | return songList(lines);
302 | }
303 | async listPlaylists() {
304 | let lines = await this.command("listplaylists");
305 | let parsed = linesToStruct(lines);
306 | let list = parsed.playlist;
307 | if (!list) {
308 | return [];
309 | }
310 | return list instanceof Array ? list : [list];
311 | }
312 | async listPlaylistItems(name) {
313 | let lines = await this.command(`listplaylistinfo "${escape(name)}"`);
314 | return songList(lines);
315 | }
316 | async listPath(path) {
317 | let lines = await this.command(`lsinfo "${escape(path)}"`);
318 | return pathContents(lines);
319 | }
320 | async listTags(tag, filter = {}) {
321 | let tokens = ["list", tag];
322 | if (Object.keys(filter).length) {
323 | tokens.push(serializeFilter(filter));
324 | let fakeGroup = Object.keys(filter)[0];
325 | tokens.push("group", fakeGroup);
326 | }
327 | let lines = await this.command(tokens.join(" "));
328 | let parsed = linesToStruct(lines);
329 | return [].concat(tag in parsed ? parsed[tag] : []);
330 | }
331 | async listSongs(filter, window2) {
332 | let tokens = ["find", serializeFilter(filter)];
333 | window2 && tokens.push("window", window2.join(":"));
334 | let lines = await this.command(tokens.join(" "));
335 | return songList(lines);
336 | }
337 | async searchSongs(filter) {
338 | let tokens = ["search", serializeFilter(filter, "contains")];
339 | let lines = await this.command(tokens.join(" "));
340 | return songList(lines);
341 | }
342 | async albumArt(songUrl) {
343 | let data = [];
344 | let offset = 0;
345 | let params = ["albumart", `"${escape(songUrl)}"`, offset];
346 | while (1) {
347 | params[2] = offset;
348 | try {
349 | let lines = await this.command(params.join(" "));
350 | data = data.concat(lines[2]);
351 | let metadata = linesToStruct(lines.slice(0, 2));
352 | if (data.length >= Number(metadata.size)) {
353 | return data;
354 | }
355 | offset += Number(metadata.binary);
356 | } catch (e) {
357 | return null;
358 | }
359 | }
360 | return null;
361 | }
362 | _onMessage(e) {
363 | if (!this.current) {
364 | return;
365 | }
366 | let lines = JSON.parse(e.data);
367 | let last = lines.pop();
368 | if (last.startsWith("OK")) {
369 | this.current.resolve(lines);
370 | } else {
371 | console.warn(last);
372 | this.current.reject(last);
373 | }
374 | this.current = void 0;
375 | if (this.queue.length > 0) {
376 | this.advanceQueue();
377 | } else {
378 | setTimeout(() => this.idle(), 0);
379 | }
380 | }
381 | _onClose(e) {
382 | console.warn(e);
383 | this.current && this.current.reject(e);
384 | this.onClose(e);
385 | }
386 | advanceQueue() {
387 | this.current = this.queue.shift();
388 | this.ws.send(this.current.cmd);
389 | }
390 | async idle() {
391 | if (this.current) {
392 | return;
393 | }
394 | this.canTerminateIdle = true;
395 | let lines = await this.command("idle stored_playlist playlist player options mixer");
396 | this.canTerminateIdle = false;
397 | let changed = linesToStruct(lines).changed || [];
398 | changed = [].concat(changed);
399 | changed.length > 0 && this.onChange(changed);
400 | }
401 | };
402 | function escape(str) {
403 | return str.replace(/(['"\\])/g, "\\$1");
404 | }
405 | function serializeFilter(filter, operator = "==") {
406 | let tokens = ["("];
407 | Object.entries(filter).forEach(([key, value], index) => {
408 | index && tokens.push(" AND ");
409 | tokens.push(`(${key} ${operator} "${escape(value)}")`);
410 | });
411 | tokens.push(")");
412 | let filterStr = tokens.join("");
413 | return `"${escape(filterStr)}"`;
414 | }
415 | function createURL(ticket) {
416 | let url = new URL(location.href);
417 | url.protocol = url.protocol == "https:" ? "wss" : "ws";
418 | url.hash = "";
419 | url.searchParams.set("ticket", ticket);
420 | return url;
421 | }
422 |
423 | // app/js/icons.ts
424 | var ICONS = {};
425 | ICONS["playlist-music"] = ``;
428 | ICONS["folder"] = ``;
431 | ICONS["shuffle"] = ``;
434 | ICONS["artist"] = ``;
437 | ICONS["download"] = ``;
440 | ICONS["checkbox-marked-outline"] = ``;
443 | ICONS["magnify"] = ``;
446 | ICONS["delete"] = ``;
449 | ICONS["rewind"] = ``;
452 | ICONS["cancel"] = ``;
455 | ICONS["settings"] = ``;
458 | ICONS["pause"] = ``;
461 | ICONS["arrow-down-bold"] = ``;
464 | ICONS["filter-variant"] = ``;
467 | ICONS["volume-off"] = ``;
470 | ICONS["close"] = ``;
473 | ICONS["music"] = ``;
476 | ICONS["repeat"] = ``;
479 | ICONS["arrow-up-bold"] = ``;
482 | ICONS["keyboard-backspace"] = ``;
485 | ICONS["play"] = ``;
488 | ICONS["plus"] = ``;
491 | ICONS["content-save"] = ``;
494 | ICONS["library-music"] = ``;
497 | ICONS["fast-forward"] = ``;
500 | ICONS["volume-high"] = ``;
503 | ICONS["chevron-double-right"] = ``;
506 | ICONS["album"] = ``;
509 | ICONS["minus_unused"] = ``;
512 | var icons_default = ICONS;
513 |
514 | // app/js/html.ts
515 | function node(name, attrs, content, parent) {
516 | let n = document.createElement(name);
517 | Object.assign(n, attrs);
518 | if (attrs && attrs.title) {
519 | n.setAttribute("aria-label", attrs.title);
520 | }
521 | content && text(content, n);
522 | parent && parent.append(n);
523 | return n;
524 | }
525 | function icon(type, parent) {
526 | let str = icons_default[type];
527 | if (!str) {
528 | console.error("Bad icon type '%s'", type);
529 | return node("span", {}, "\u203D");
530 | }
531 | let tmp = node("div");
532 | tmp.innerHTML = str;
533 | let s = tmp.querySelector("svg");
534 | if (!s) {
535 | throw new Error(`Bad icon source for type '${type}'`);
536 | }
537 | s.classList.add("icon");
538 | s.classList.add(`icon-${type}`);
539 | parent && parent.append(s);
540 | return s;
541 | }
542 | function button(attrs, content, parent) {
543 | let result = node("button", attrs, content, parent);
544 | if (attrs && attrs.icon) {
545 | let i = icon(attrs.icon);
546 | result.insertBefore(i, result.firstChild);
547 | }
548 | return result;
549 | }
550 | function clear(node2) {
551 | while (node2.firstChild) {
552 | node2.firstChild.remove();
553 | }
554 | return node2;
555 | }
556 | function text(txt, parent) {
557 | let n = document.createTextNode(txt);
558 | parent && parent.append(n);
559 | return n;
560 | }
561 |
562 | // app/js/selection.ts
563 | var Selection = class {
564 | constructor() {
565 | this.commands = new Commands();
566 | this.items = [];
567 | this.hide();
568 | }
569 | configure(items, mode, commands) {
570 | this.mode = mode;
571 | let allCommands = [];
572 | if (mode == "multi") {
573 | allCommands.push({
574 | cb: () => items.forEach((item) => this.add(item)),
575 | label: "Select all",
576 | icon: "checkbox-marked-outline"
577 | });
578 | }
579 | allCommands.push(...commands);
580 | allCommands.push({
581 | cb: () => this.clear(),
582 | icon: "cancel",
583 | label: "Cancel",
584 | className: "last"
585 | });
586 | let buttons = allCommands.map((command) => {
587 | let button2 = buildButton(command);
588 | button2.addEventListener("click", (_) => {
589 | const arg = mode == "single" ? this.items[0] : this.items;
590 | command.cb(arg);
591 | });
592 | return button2;
593 | });
594 | this.commands.innerHTML = "";
595 | this.commands.append(...buttons);
596 | items.forEach((item) => {
597 | item.onclick = () => this.toggle(item);
598 | });
599 | this.clear();
600 | }
601 | clear() {
602 | while (this.items.length) {
603 | this.remove(this.items[0]);
604 | }
605 | }
606 | toggle(node2) {
607 | if (this.items.includes(node2)) {
608 | this.remove(node2);
609 | } else {
610 | this.add(node2);
611 | }
612 | }
613 | add(node2) {
614 | if (this.items.includes(node2)) {
615 | return;
616 | }
617 | const length = this.items.length;
618 | this.items.push(node2);
619 | node2.classList.add("selected");
620 | if (this.mode == "single" && length > 0) {
621 | this.remove(this.items[0]);
622 | }
623 | if (length == 0) {
624 | this.show();
625 | }
626 | }
627 | remove(node2) {
628 | const index = this.items.indexOf(node2);
629 | this.items.splice(index, 1);
630 | node2.classList.remove("selected");
631 | if (this.items.length == 0) {
632 | this.hide();
633 | }
634 | }
635 | show() {
636 | this.commands.hidden = false;
637 | }
638 | hide() {
639 | this.commands.hidden = true;
640 | }
641 | };
642 | function buildButton(command) {
643 | const button2 = button({ icon: command.icon });
644 | if (command.className) {
645 | button2.className = command.className;
646 | }
647 | node("span", {}, command.label, button2);
648 | return button2;
649 | }
650 | var Commands = class extends HTMLElement {
651 | };
652 | customElements.define("cyp-commands", Commands);
653 |
654 | // app/js/elements/app.ts
655 | function initIcons() {
656 | [...document.querySelectorAll("[data-icon]")].forEach((node2) => {
657 | node2.dataset.icon.split(" ").forEach((name) => {
658 | let icon2 = icon(name);
659 | node2.prepend(icon2);
660 | });
661 | });
662 | }
663 | var App = class extends HTMLElement {
664 | constructor() {
665 | super();
666 | initIcons();
667 | }
668 | static get observedAttributes() {
669 | return ["component"];
670 | }
671 | async connectedCallback() {
672 | await waitForChildren(this);
673 | window.addEventListener("hashchange", (e) => this.onHashChange());
674 | this.onHashChange();
675 | await this.connect();
676 | this.dispatchEvent(new CustomEvent("load"));
677 | this.initMediaHandler();
678 | }
679 | attributeChangedCallback(name, oldValue, newValue) {
680 | switch (name) {
681 | case "component":
682 | location.hash = newValue;
683 | const e = new CustomEvent("component-change");
684 | this.dispatchEvent(e);
685 | break;
686 | }
687 | }
688 | get component() {
689 | return this.getAttribute("component") || "";
690 | }
691 | set component(component) {
692 | this.setAttribute("component", component);
693 | }
694 | createSelection() {
695 | let selection = new Selection();
696 | this.querySelector("footer").append(selection.commands);
697 | return selection;
698 | }
699 | onHashChange() {
700 | const component = location.hash.substring(1) || "queue";
701 | if (component != this.component) {
702 | this.component = component;
703 | }
704 | }
705 | onChange(changed) {
706 | this.dispatchEvent(new CustomEvent("idle-change", { detail: changed }));
707 | }
708 | async onClose(e) {
709 | await sleep(3e3);
710 | this.connect();
711 | }
712 | async connect() {
713 | const attempts = 3;
714 | for (let i = 0; i < attempts; i++) {
715 | try {
716 | let mpd = await MPD.connect();
717 | mpd.onChange = (changed) => this.onChange(changed);
718 | mpd.onClose = (e) => this.onClose(e);
719 | this.mpd = mpd;
720 | return;
721 | } catch (e) {
722 | await sleep(500);
723 | }
724 | }
725 | alert(`Failed to connect to MPD after ${attempts} attempts. Please reload the page to try again.`);
726 | }
727 | initMediaHandler() {
728 | if (!("mediaSession" in navigator)) {
729 | console.log("mediaSession is not supported");
730 | return;
731 | }
732 | const audio = node("audio", { loop: true }, "", this);
733 | node("source", { src: "https://raw.githubusercontent.com/anars/blank-audio/master/10-seconds-of-silence.mp3" }, "", audio);
734 | window.addEventListener("click", () => {
735 | audio.play();
736 | }, { once: true });
737 | navigator.mediaSession.metadata = new MediaMetadata({
738 | title: "Control Your Player"
739 | });
740 | navigator.mediaSession.setActionHandler("play", () => {
741 | this.mpd.command("play");
742 | audio.play();
743 | });
744 | navigator.mediaSession.setActionHandler("pause", () => {
745 | this.mpd.command("pause 1");
746 | audio.pause();
747 | });
748 | navigator.mediaSession.setActionHandler("previoustrack", () => {
749 | this.mpd.command("previous");
750 | audio.play();
751 | });
752 | navigator.mediaSession.setActionHandler("nexttrack", () => {
753 | this.mpd.command("next");
754 | audio.play();
755 | });
756 | }
757 | };
758 | customElements.define("cyp-app", App);
759 | function sleep(ms) {
760 | return new Promise((resolve) => setTimeout(resolve, ms));
761 | }
762 | function waitForChildren(app) {
763 | const children = [...app.querySelectorAll("*")];
764 | const names = children.map((node2) => node2.nodeName.toLowerCase()).filter((name) => name.startsWith("cyp-"));
765 | const unique = new Set(names);
766 | const promises = [...unique].map((name) => customElements.whenDefined(name));
767 | return Promise.all(promises);
768 | }
769 |
770 | // app/js/component.ts
771 | var Component = class extends HTMLElement {
772 | connectedCallback() {
773 | const { app } = this;
774 | app.addEventListener("load", (_) => this.onAppLoad());
775 | app.addEventListener("component-change", (_) => {
776 | const component = app.component;
777 | const isThis = this.nodeName.toLowerCase() == `cyp-${component}`;
778 | this.onComponentChange(component, isThis);
779 | });
780 | }
781 | get app() {
782 | return this.closest("cyp-app");
783 | }
784 | get mpd() {
785 | return this.app.mpd;
786 | }
787 | onAppLoad() {
788 | }
789 | onComponentChange(component, isThis) {
790 | }
791 | };
792 |
793 | // app/js/elements/menu.ts
794 | var Menu = class extends Component {
795 | constructor() {
796 | super(...arguments);
797 | this.tabs = Array.from(this.querySelectorAll("[data-for]"));
798 | }
799 | connectedCallback() {
800 | super.connectedCallback();
801 | this.tabs.forEach((tab) => {
802 | tab.addEventListener("click", (_) => this.app.setAttribute("component", tab.dataset.for));
803 | });
804 | }
805 | onAppLoad() {
806 | this.app.addEventListener("queue-length-change", (e) => {
807 | this.querySelector(".queue-length").textContent = `(${e.detail})`;
808 | });
809 | }
810 | onComponentChange(component) {
811 | this.tabs.forEach((tab) => {
812 | tab.classList.toggle("active", tab.dataset.for == component);
813 | });
814 | }
815 | };
816 | customElements.define("cyp-menu", Menu);
817 |
818 | // app/js/conf.ts
819 | var artSize = 96 * (window.devicePixelRatio || 1);
820 | var ytPath = "_youtube";
821 | var ytLimit = 3;
822 | function setYtLimit(limit) {
823 | ytLimit = limit;
824 | }
825 |
826 | // app/js/art.ts
827 | var cache = {};
828 | var MIME = "image/jpeg";
829 | var STORAGE_PREFIX = `art-${artSize}`;
830 | function store(key, data) {
831 | localStorage.setItem(`${STORAGE_PREFIX}-${key}`, data);
832 | }
833 | function load(key) {
834 | return localStorage.getItem(`${STORAGE_PREFIX}-${key}`);
835 | }
836 | async function bytesToImage(bytes) {
837 | const blob = new Blob([bytes]);
838 | const src = URL.createObjectURL(blob);
839 | const image = node("img", { src });
840 | return new Promise((resolve) => {
841 | image.onload = () => resolve(image);
842 | });
843 | }
844 | function resize(image) {
845 | while (Math.min(image.width, image.height) >= 2 * artSize) {
846 | let tmp = node("canvas", { width: image.width / 2, height: image.height / 2 });
847 | tmp.getContext("2d").drawImage(image, 0, 0, tmp.width, tmp.height);
848 | image = tmp;
849 | }
850 | const canvas = node("canvas", { width: artSize, height: artSize });
851 | canvas.getContext("2d").drawImage(image, 0, 0, canvas.width, canvas.height);
852 | return canvas;
853 | }
854 | async function get(mpd, artist, album, songUrl) {
855 | const key = `${artist}-${album}`;
856 | if (key in cache) {
857 | return cache[key];
858 | }
859 | const loaded = load(key);
860 | if (loaded) {
861 | cache[key] = loaded;
862 | return loaded;
863 | }
864 | if (!songUrl) {
865 | return null;
866 | }
867 | let resolve;
868 | const promise = new Promise((res) => resolve = res);
869 | cache[key] = promise;
870 | const data = await mpd.albumArt(songUrl);
871 | if (data) {
872 | const bytes = new Uint8Array(data);
873 | const image = await bytesToImage(bytes);
874 | const url = resize(image).toDataURL(MIME);
875 | store(key, url);
876 | cache[key] = url;
877 | resolve(url);
878 | } else {
879 | cache[key] = null;
880 | }
881 | return cache[key];
882 | }
883 |
884 | // app/js/format.ts
885 | var SEPARATOR = " \xB7 ";
886 | function time(sec) {
887 | sec = Math.round(sec);
888 | let m = Math.floor(sec / 60);
889 | let s = sec % 60;
890 | return `${m}:${s.toString().padStart(2, "0")}`;
891 | }
892 | function subtitle(data, options = { duration: true }) {
893 | let tokens = [];
894 | if (data.Artist) {
895 | tokens.push(data.Artist);
896 | } else if (data.AlbumArtist) {
897 | tokens.push(data.AlbumArtist);
898 | }
899 | data.Album && tokens.push(data.Album);
900 | options.duration && data.duration && tokens.push(time(Number(data.duration)));
901 | return tokens.join(SEPARATOR);
902 | }
903 | function fileName(file) {
904 | return file.split("/").pop() || "";
905 | }
906 |
907 | // app/js/elements/player.ts
908 | var ELAPSED_PERIOD = 500;
909 | var Player = class extends Component {
910 | constructor() {
911 | super();
912 | this.current = {
913 | song: {},
914 | elapsed: 0,
915 | at: 0,
916 | volume: 0
917 | };
918 | this.toggleVolume = 0;
919 | const DOM = {};
920 | const all = this.querySelectorAll("[class]");
921 | [...all].forEach((node2) => DOM[node2.className] = node2);
922 | DOM.progress = DOM.timeline.querySelector("x-range");
923 | DOM.volume = DOM.volume.querySelector("x-range");
924 | this.DOM = DOM;
925 | }
926 | handleEvent(e) {
927 | switch (e.type) {
928 | case "idle-change":
929 | let hasOptions = e.detail.includes("options");
930 | let hasPlayer = e.detail.includes("player");
931 | let hasMixer = e.detail.includes("mixer");
932 | (hasOptions || hasPlayer || hasMixer) && this.updateStatus();
933 | hasPlayer && this.updateCurrent();
934 | break;
935 | }
936 | }
937 | onAppLoad() {
938 | this.addEvents();
939 | this.updateStatus();
940 | this.updateCurrent();
941 | this.app.addEventListener("idle-change", this);
942 | setInterval(() => this.updateElapsed(), ELAPSED_PERIOD);
943 | }
944 | async updateStatus() {
945 | const { current, mpd } = this;
946 | const data = await mpd.status();
947 | this.updateFlags(data);
948 | this.updateVolume(data);
949 | current.elapsed = Number(data.elapsed || 0);
950 | current.at = performance.now();
951 | }
952 | async updateCurrent() {
953 | const { current, mpd, DOM } = this;
954 | const data = await mpd.currentSong();
955 | if (data.file != current.song.file) {
956 | if (data.file) {
957 | DOM.title.textContent = data.Title || fileName(data.file);
958 | DOM.subtitle.textContent = subtitle(data, { duration: false });
959 | let duration = Number(data.duration);
960 | DOM.duration.textContent = time(duration);
961 | DOM.progress.max = String(duration);
962 | DOM.progress.disabled = false;
963 | } else {
964 | DOM.title.textContent = "";
965 | DOM.subtitle.textContent = "";
966 | DOM.progress.value = "0";
967 | DOM.progress.disabled = true;
968 | }
969 | this.dispatchSongChange(data);
970 | }
971 | let artistNew = data.Artist || data.AlbumArtist || "";
972 | let artistOld = current.song["Artist"] || current.song["AlbumArtist"];
973 | let albumNew = data["Album"];
974 | let albumOld = current.song["Album"];
975 | Object.assign(current.song, data);
976 | if (artistNew != artistOld || albumNew != albumOld) {
977 | clear(DOM.art);
978 | let src = await get(mpd, artistNew, data.Album || "", data.file);
979 | if (src) {
980 | node("img", { src }, "", DOM.art);
981 | } else {
982 | icon("music", DOM.art);
983 | }
984 | }
985 | }
986 | updateElapsed() {
987 | const { current, DOM } = this;
988 | let elapsed = 0;
989 | if (current.song["file"]) {
990 | elapsed = current.elapsed;
991 | if (this.dataset.state == "play") {
992 | elapsed += (performance.now() - current.at) / 1e3;
993 | }
994 | }
995 | let progress = DOM.progress;
996 | progress.value = String(elapsed);
997 | DOM.elapsed.textContent = time(elapsed);
998 | this.app.style.setProperty("--progress", String(elapsed / Number(progress.max)));
999 | }
1000 | updateFlags(data) {
1001 | let flags = [];
1002 | if (data.random == "1") {
1003 | flags.push("random");
1004 | }
1005 | if (data.repeat == "1") {
1006 | flags.push("repeat");
1007 | }
1008 | if (data.volume === "0") {
1009 | flags.push("mute");
1010 | }
1011 | this.dataset.flags = flags.join(" ");
1012 | this.dataset.state = data["state"];
1013 | }
1014 | updateVolume(data) {
1015 | const { current, DOM } = this;
1016 | if ("volume" in data) {
1017 | let volume = Number(data.volume);
1018 | DOM.mute.disabled = false;
1019 | DOM.volume.disabled = false;
1020 | DOM.volume.value = String(volume);
1021 | if (volume == 0 && current.volume > 0) {
1022 | this.toggleVolume = current.volume;
1023 | }
1024 | if (volume > 0 && current.volume == 0) {
1025 | this.toggleVolume = 0;
1026 | }
1027 | current.volume = volume;
1028 | } else {
1029 | DOM.mute.disabled = true;
1030 | DOM.volume.disabled = true;
1031 | DOM.volume.value = String(50);
1032 | }
1033 | }
1034 | addEvents() {
1035 | const { current, mpd, DOM } = this;
1036 | DOM.play.addEventListener("click", (_) => mpd.command("play"));
1037 | DOM.pause.addEventListener("click", (_) => mpd.command("pause 1"));
1038 | DOM.prev.addEventListener("click", (_) => mpd.command("previous"));
1039 | DOM.next.addEventListener("click", (_) => mpd.command("next"));
1040 | DOM.random.addEventListener("click", (_) => {
1041 | let isRandom = this.dataset.flags.split(" ").includes("random");
1042 | mpd.command(`random ${isRandom ? "0" : "1"}`);
1043 | });
1044 | DOM.repeat.addEventListener("click", (_) => {
1045 | let isRepeat = this.dataset.flags.split(" ").includes("repeat");
1046 | mpd.command(`repeat ${isRepeat ? "0" : "1"}`);
1047 | });
1048 | DOM.progress.addEventListener("input", (e) => {
1049 | let elapsed = e.target.valueAsNumber;
1050 | current.elapsed = elapsed;
1051 | current.at = performance.now();
1052 | mpd.command(`seekcur ${elapsed}`);
1053 | });
1054 | DOM.volume.addEventListener("input", (e) => mpd.command(`setvol ${e.target.valueAsNumber}`));
1055 | DOM.mute.addEventListener("click", () => mpd.command(`setvol ${this.toggleVolume}`));
1056 | }
1057 | dispatchSongChange(detail) {
1058 | const e = new CustomEvent("song-change", { detail });
1059 | this.app.dispatchEvent(e);
1060 | }
1061 | };
1062 | customElements.define("cyp-player", Player);
1063 |
1064 | // app/js/item.ts
1065 | var Item = class extends HTMLElement {
1066 | addButton(icon2, cb) {
1067 | button({ icon: icon2 }, "", this).addEventListener("click", (e) => {
1068 | e.stopPropagation();
1069 | cb();
1070 | });
1071 | }
1072 | buildTitle(title) {
1073 | return node("span", { className: "title" }, title, this);
1074 | }
1075 | matchPrefix(prefix2) {
1076 | return (this.textContent || "").match(/\w+/g).some((word) => word.toLowerCase().startsWith(prefix2));
1077 | }
1078 | };
1079 |
1080 | // app/js/elements/song.ts
1081 | var Song = class extends Item {
1082 | constructor(data) {
1083 | super();
1084 | this.data = data;
1085 | icon("music", this);
1086 | icon("play", this);
1087 | const block = node("div", { className: "multiline" }, "", this);
1088 | const title = this.buildSongTitle(data);
1089 | block.append(title);
1090 | if (data.Track) {
1091 | const track = node("span", { className: "track" }, data.Track.padStart(2, "0"));
1092 | title.insertBefore(text(" "), title.firstChild);
1093 | title.insertBefore(track, title.firstChild);
1094 | }
1095 | if (data.Title) {
1096 | const subtitle2 = subtitle(data);
1097 | node("span", { className: "subtitle" }, subtitle2, block);
1098 | }
1099 | this.playing = false;
1100 | }
1101 | get file() {
1102 | return this.data.file;
1103 | }
1104 | get songId() {
1105 | return this.data.Id;
1106 | }
1107 | set playing(playing) {
1108 | this.classList.toggle("playing", playing);
1109 | }
1110 | buildSongTitle(data) {
1111 | return super.buildTitle(data.Title || fileName(this.file));
1112 | }
1113 | };
1114 | customElements.define("cyp-song", Song);
1115 |
1116 | // app/js/elements/queue.ts
1117 | function generateMoveCommands(items, diff, parent) {
1118 | let all = [...parent.children].filter((node2) => node2 instanceof Song);
1119 | const COMPARE = (a, b) => all.indexOf(a) - all.indexOf(b);
1120 | return items.sort(COMPARE).map((item) => {
1121 | let index = all.indexOf(item) + diff;
1122 | if (index < 0 || index >= all.length) {
1123 | return null;
1124 | }
1125 | return `moveid ${item.songId} ${index}`;
1126 | }).filter((command) => command);
1127 | }
1128 | var Queue = class extends Component {
1129 | handleEvent(e) {
1130 | switch (e.type) {
1131 | case "song-change":
1132 | this.currentId = e.detail["Id"];
1133 | this.updateCurrent();
1134 | break;
1135 | case "idle-change":
1136 | e.detail.includes("playlist") && this.sync();
1137 | break;
1138 | }
1139 | }
1140 | onAppLoad() {
1141 | const { app } = this;
1142 | this.selection = app.createSelection();
1143 | app.addEventListener("idle-change", this);
1144 | app.addEventListener("song-change", this);
1145 | this.sync();
1146 | }
1147 | onComponentChange(c, isThis) {
1148 | this.hidden = !isThis;
1149 | }
1150 | async sync() {
1151 | let songs = await this.mpd.listQueue();
1152 | this.buildSongs(songs);
1153 | let e = new CustomEvent("queue-length-change", { detail: songs.length });
1154 | this.app.dispatchEvent(e);
1155 | }
1156 | updateCurrent() {
1157 | let songs = [...this.children].filter((node2) => node2 instanceof Song);
1158 | songs.forEach((node2) => {
1159 | node2.playing = node2.songId == this.currentId;
1160 | });
1161 | }
1162 | buildSongs(songs) {
1163 | clear(this);
1164 | let nodes = songs.map((song) => {
1165 | let node2 = new Song(song);
1166 | node2.addButton("play", async () => {
1167 | await this.mpd.command(`playid ${node2.songId}`);
1168 | });
1169 | return node2;
1170 | });
1171 | this.append(...nodes);
1172 | this.configureSelection(nodes);
1173 | this.updateCurrent();
1174 | }
1175 | configureSelection(nodes) {
1176 | const { mpd, selection } = this;
1177 | let commands = [{
1178 | cb: (items) => {
1179 | const commands2 = generateMoveCommands(items, -1, this);
1180 | mpd.command(commands2);
1181 | },
1182 | label: "Up",
1183 | icon: "arrow-up-bold"
1184 | }, {
1185 | cb: (items) => {
1186 | const commands2 = generateMoveCommands(items, 1, this);
1187 | mpd.command(commands2.reverse());
1188 | },
1189 | label: "Down",
1190 | icon: "arrow-down-bold"
1191 | }, {
1192 | cb: async (items) => {
1193 | let name = prompt("Save selected songs as a playlist?", "name");
1194 | if (name === null) {
1195 | return;
1196 | }
1197 | name = escape(name);
1198 | try {
1199 | await mpd.command(`rm "${name}"`);
1200 | } catch (e) {
1201 | }
1202 | const commands2 = items.map((item) => {
1203 | return `playlistadd "${name}" "${escape(item.file)}"`;
1204 | });
1205 | await mpd.command(commands2);
1206 | selection.clear();
1207 | },
1208 | label: "Save",
1209 | icon: "content-save"
1210 | }, {
1211 | cb: async (items) => {
1212 | if (!confirm(`Remove these ${items.length} songs from the queue?`)) {
1213 | return;
1214 | }
1215 | const commands2 = items.map((item) => `deleteid ${item.songId}`);
1216 | mpd.command(commands2);
1217 | },
1218 | label: "Remove",
1219 | icon: "delete"
1220 | }];
1221 | selection.configure(nodes, "multi", commands);
1222 | }
1223 | };
1224 | customElements.define("cyp-queue", Queue);
1225 |
1226 | // app/js/elements/playlist.ts
1227 | var Playlist = class extends Item {
1228 | constructor(name) {
1229 | super();
1230 | this.name = name;
1231 | icon("playlist-music", this);
1232 | this.buildTitle(name);
1233 | }
1234 | };
1235 | customElements.define("cyp-playlist", Playlist);
1236 |
1237 | // app/js/elements/back.ts
1238 | var Back = class extends Item {
1239 | constructor(title) {
1240 | super();
1241 | this.append(icon("keyboard-backspace"));
1242 | this.buildTitle(title);
1243 | }
1244 | };
1245 | customElements.define("cyp-back", Back);
1246 |
1247 | // app/js/elements/playlists.ts
1248 | var Playlists = class extends Component {
1249 | handleEvent(e) {
1250 | switch (e.type) {
1251 | case "idle-change":
1252 | e.detail.includes("stored_playlist") && this.sync();
1253 | break;
1254 | }
1255 | }
1256 | onAppLoad() {
1257 | const { app } = this;
1258 | this.selection = app.createSelection();
1259 | app.addEventListener("idle-change", this);
1260 | this.sync();
1261 | }
1262 | onComponentChange(c, isThis) {
1263 | this.hidden = !isThis;
1264 | }
1265 | async sync() {
1266 | if (this.current) {
1267 | let songs = await this.mpd.listPlaylistItems(this.current);
1268 | this.buildSongs(songs);
1269 | } else {
1270 | let lists = await this.mpd.listPlaylists();
1271 | this.buildLists(lists);
1272 | }
1273 | }
1274 | buildSongs(songs) {
1275 | clear(this);
1276 | this.buildBack();
1277 | let nodes = songs.map((song) => new Song(song));
1278 | this.append(...nodes);
1279 | this.configureSelectionSongs(nodes);
1280 | }
1281 | buildLists(lists) {
1282 | clear(this);
1283 | let playlists = lists.map((name) => {
1284 | let node2 = new Playlist(name);
1285 | node2.addButton("chevron-double-right", () => {
1286 | this.current = name;
1287 | this.sync();
1288 | });
1289 | return node2;
1290 | });
1291 | this.append(...playlists);
1292 | this.configureSelectionLists(playlists);
1293 | }
1294 | buildBack() {
1295 | const node2 = new Back("Playlists");
1296 | this.append(node2);
1297 | node2.onclick = () => {
1298 | this.current = void 0;
1299 | this.sync();
1300 | };
1301 | }
1302 | configureSelectionSongs(songs) {
1303 | const { selection, mpd } = this;
1304 | let commands = [{
1305 | cb: async (items) => {
1306 | await mpd.command(["clear", ...items.map(createAddCommand), "play"]);
1307 | selection.clear();
1308 | },
1309 | label: "Play",
1310 | icon: "play"
1311 | }, {
1312 | cb: async (items) => {
1313 | await mpd.command(items.map(createAddCommand));
1314 | selection.clear();
1315 | },
1316 | label: "Enqueue",
1317 | icon: "plus"
1318 | }];
1319 | selection.configure(songs, "multi", commands);
1320 | }
1321 | configureSelectionLists(lists) {
1322 | const { mpd, selection } = this;
1323 | let commands = [{
1324 | cb: async (item) => {
1325 | const name = item.name;
1326 | const commands2 = ["clear", `load "${escape(name)}"`, "play"];
1327 | await mpd.command(commands2);
1328 | selection.clear();
1329 | },
1330 | label: "Play",
1331 | icon: "play"
1332 | }, {
1333 | cb: async (item) => {
1334 | const name = item.name;
1335 | await mpd.command(`load "${escape(name)}"`);
1336 | selection.clear();
1337 | },
1338 | label: "Enqueue",
1339 | icon: "plus"
1340 | }, {
1341 | cb: async (item) => {
1342 | const name = item.name;
1343 | if (!confirm(`Really delete playlist '${name}'?`)) {
1344 | return;
1345 | }
1346 | await mpd.command(`rm "${escape(name)}"`);
1347 | },
1348 | label: "Delete",
1349 | icon: "delete"
1350 | }];
1351 | selection.configure(lists, "single", commands);
1352 | }
1353 | };
1354 | customElements.define("cyp-playlists", Playlists);
1355 | function createAddCommand(node2) {
1356 | return `add "${escape(node2.file)}"`;
1357 | }
1358 |
1359 | // app/js/elements/settings.ts
1360 | var prefix = "cyp";
1361 | function loadFromStorage(key) {
1362 | return localStorage.getItem(`${prefix}-${key}`);
1363 | }
1364 | function saveToStorage(key, value) {
1365 | return localStorage.setItem(`${prefix}-${key}`, String(value));
1366 | }
1367 | var Settings = class extends Component {
1368 | constructor() {
1369 | super(...arguments);
1370 | this.inputs = {
1371 | theme: this.querySelector("[name=theme]"),
1372 | ytLimit: this.querySelector("[name=yt-limit]"),
1373 | color: [...this.querySelectorAll("[name=color]")]
1374 | };
1375 | }
1376 | onAppLoad() {
1377 | const { inputs } = this;
1378 | let mo = new MutationObserver((mrs) => {
1379 | mrs.forEach((mr) => this._onAppAttributeChange(mr));
1380 | });
1381 | mo.observe(this.app, { attributes: true });
1382 | inputs.theme.addEventListener("change", (e) => this.setTheme(e.target.value));
1383 | inputs.ytLimit.addEventListener("change", (e) => this.setYtLimit(Number(e.target.value)));
1384 | inputs.color.forEach((input) => {
1385 | input.addEventListener("click", (e) => this.setColor(e.target.value));
1386 | });
1387 | const theme = loadFromStorage("theme");
1388 | theme ? this.app.setAttribute("theme", theme) : this.syncTheme();
1389 | const color = loadFromStorage("color");
1390 | color ? this.app.setAttribute("color", color) : this.syncColor();
1391 | const ytLimit2 = loadFromStorage("ytLimit") || ytLimit;
1392 | this.setYtLimit(Number(ytLimit2));
1393 | }
1394 | _onAppAttributeChange(mr) {
1395 | if (mr.attributeName == "theme") {
1396 | this.syncTheme();
1397 | }
1398 | if (mr.attributeName == "color") {
1399 | this.syncColor();
1400 | }
1401 | }
1402 | syncTheme() {
1403 | this.inputs.theme.value = this.app.getAttribute("theme");
1404 | }
1405 | syncColor() {
1406 | this.inputs.color.forEach((input) => {
1407 | input.checked = input.value == this.app.getAttribute("color");
1408 | input.parentElement.style.color = input.value;
1409 | });
1410 | }
1411 | setTheme(theme) {
1412 | saveToStorage("theme", theme);
1413 | this.app.setAttribute("theme", theme);
1414 | }
1415 | setColor(color) {
1416 | saveToStorage("color", color);
1417 | this.app.setAttribute("color", color);
1418 | }
1419 | setYtLimit(ytLimit2) {
1420 | saveToStorage("ytLimit", ytLimit2);
1421 | setYtLimit(ytLimit2);
1422 | }
1423 | onComponentChange(c, isThis) {
1424 | this.hidden = !isThis;
1425 | }
1426 | };
1427 | customElements.define("cyp-settings", Settings);
1428 |
1429 | // app/js/elements/search.ts
1430 | var Search = class extends HTMLElement {
1431 | constructor() {
1432 | super();
1433 | const form = node("form", {}, "", this);
1434 | node("input", { type: "text" }, "", form);
1435 | button({ icon: "magnify" }, "", form);
1436 | form.addEventListener("submit", (e) => {
1437 | e.preventDefault();
1438 | this.onSubmit();
1439 | });
1440 | }
1441 | get value() {
1442 | return this.input.value.trim();
1443 | }
1444 | set value(value) {
1445 | this.input.value = value;
1446 | }
1447 | get input() {
1448 | return this.querySelector("input");
1449 | }
1450 | onSubmit() {
1451 | }
1452 | focus() {
1453 | this.input.focus();
1454 | }
1455 | pending(pending) {
1456 | this.classList.toggle("pending", pending);
1457 | }
1458 | };
1459 | customElements.define("cyp-search", Search);
1460 |
1461 | // app/js/elements/yt-result.ts
1462 | var YtResult = class extends Item {
1463 | constructor(title) {
1464 | super();
1465 | this.append(icon("magnify"));
1466 | this.buildTitle(title);
1467 | }
1468 | };
1469 | customElements.define("cyp-yt-result", YtResult);
1470 |
1471 | // app/js/elements/yt.ts
1472 | var YT = class extends Component {
1473 | constructor() {
1474 | super();
1475 | this.search = new Search();
1476 | this.search.onSubmit = () => {
1477 | let query = this.search.value;
1478 | query && this.doSearch(query);
1479 | };
1480 | this.clear();
1481 | }
1482 | clear() {
1483 | clear(this);
1484 | this.append(this.search);
1485 | }
1486 | async doSearch(query) {
1487 | this.clear();
1488 | this.search.pending(true);
1489 | let url = `youtube?q=${encodeURIComponent(query)}&limit=${encodeURIComponent(ytLimit)}`;
1490 | let response = await fetch(url);
1491 | if (response.status == 200) {
1492 | let results = await response.json();
1493 | results.forEach((result) => {
1494 | let node2 = new YtResult(result.title);
1495 | this.append(node2);
1496 | node2.addButton("download", () => this.download(result.id));
1497 | });
1498 | } else {
1499 | let text2 = await response.text();
1500 | alert(text2);
1501 | }
1502 | this.search.pending(false);
1503 | }
1504 | async download(id) {
1505 | this.clear();
1506 | let pre = node("pre", {}, "", this);
1507 | this.search.pending(true);
1508 | let body = new URLSearchParams();
1509 | body.set("id", id);
1510 | let response = await fetch("youtube", { method: "POST", body });
1511 | let reader = response.body.getReader();
1512 | while (true) {
1513 | let { done, value } = await reader.read();
1514 | if (done) {
1515 | break;
1516 | }
1517 | pre.textContent += decodeChunk(value);
1518 | pre.scrollTop = pre.scrollHeight;
1519 | }
1520 | reader.releaseLock();
1521 | this.search.pending(false);
1522 | if (response.status == 200) {
1523 | this.mpd.command(`update ${escape(ytPath)}`);
1524 | }
1525 | }
1526 | onComponentChange(c, isThis) {
1527 | const wasHidden = this.hidden;
1528 | this.hidden = !isThis;
1529 | if (!wasHidden && isThis) {
1530 | this.clear();
1531 | }
1532 | }
1533 | };
1534 | customElements.define("cyp-yt", YT);
1535 | var decoder = new TextDecoder("utf-8");
1536 | function decodeChunk(byteArray) {
1537 | return decoder.decode(byteArray).replace(/\u000d/g, "\n");
1538 | }
1539 |
1540 | // app/js/elements/tag.ts
1541 | var ICONS2 = {
1542 | "AlbumArtist": "artist",
1543 | "Album": "album",
1544 | "Genre": "music"
1545 | };
1546 | var Tag = class extends Item {
1547 | constructor(type, value, filter) {
1548 | super();
1549 | this.type = type;
1550 | this.value = value;
1551 | this.filter = filter;
1552 | node("span", { className: "art" }, "", this);
1553 | this.buildTitle(value);
1554 | }
1555 | createChildFilter() {
1556 | return Object.assign({ [this.type]: this.value }, this.filter);
1557 | }
1558 | async fillArt(mpd) {
1559 | const parent = this.firstChild;
1560 | const filter = this.createChildFilter();
1561 | let artist = filter["AlbumArtist"];
1562 | let album = filter["Album"];
1563 | let src = null;
1564 | if (artist && album) {
1565 | src = await get(mpd, artist, album);
1566 | if (!src) {
1567 | let songs = await mpd.listSongs(filter, [0, 1]);
1568 | if (songs.length) {
1569 | src = await get(mpd, artist, album, songs[0]["file"]);
1570 | }
1571 | }
1572 | }
1573 | if (src) {
1574 | node("img", { src }, "", parent);
1575 | } else {
1576 | const icon2 = ICONS2[this.type];
1577 | icon(icon2, parent);
1578 | }
1579 | }
1580 | };
1581 | customElements.define("cyp-tag", Tag);
1582 |
1583 | // app/js/elements/path.ts
1584 | var Path = class extends Item {
1585 | constructor(data) {
1586 | super();
1587 | this.data = data;
1588 | this.isDirectory = "directory" in this.data;
1589 | this.append(icon(this.isDirectory ? "folder" : "music"));
1590 | this.buildTitle(fileName(this.file));
1591 | }
1592 | get file() {
1593 | return this.isDirectory ? this.data.directory : this.data.file;
1594 | }
1595 | };
1596 | customElements.define("cyp-path", Path);
1597 |
1598 | // app/js/elements/filter.ts
1599 | var SELECTOR = ["cyp-tag", "cyp-path", "cyp-song"].join(", ");
1600 | var Filter = class extends HTMLElement {
1601 | constructor() {
1602 | super();
1603 | node("input", { type: "text" }, "", this);
1604 | icon("filter-variant", this);
1605 | this.input.addEventListener("input", (e) => this.apply());
1606 | }
1607 | get value() {
1608 | return this.input.value.trim();
1609 | }
1610 | set value(value) {
1611 | this.input.value = value;
1612 | }
1613 | get input() {
1614 | return this.querySelector("input");
1615 | }
1616 | apply() {
1617 | let value = this.value.toLowerCase();
1618 | let all = [...this.parentNode.querySelectorAll(SELECTOR)];
1619 | all.forEach((item) => item.hidden = !item.matchPrefix(value));
1620 | }
1621 | };
1622 | customElements.define("cyp-filter", Filter);
1623 |
1624 | // app/js/elements/library.ts
1625 | var TAGS = {
1626 | "Album": "Albums",
1627 | "AlbumArtist": "Artists",
1628 | "Genre": "Genres"
1629 | };
1630 | var Library = class extends Component {
1631 | constructor() {
1632 | super();
1633 | this.search = new Search();
1634 | this.filter = new Filter();
1635 | this.stateStack = [];
1636 | this.search.onSubmit = () => {
1637 | let query = this.search.value;
1638 | if (query.length < 3) {
1639 | return;
1640 | }
1641 | this.doSearch(query);
1642 | };
1643 | }
1644 | popState() {
1645 | this.selection.clear();
1646 | this.stateStack.pop();
1647 | if (this.stateStack.length > 0) {
1648 | let state = this.stateStack[this.stateStack.length - 1];
1649 | this.showState(state);
1650 | } else {
1651 | this.showRoot();
1652 | }
1653 | }
1654 | onAppLoad() {
1655 | this.selection = this.app.createSelection();
1656 | this.showRoot();
1657 | }
1658 | onComponentChange(c, isThis) {
1659 | const wasHidden = this.hidden;
1660 | this.hidden = !isThis;
1661 | if (!wasHidden && isThis) {
1662 | this.showRoot();
1663 | }
1664 | }
1665 | showRoot() {
1666 | this.stateStack = [];
1667 | clear(this);
1668 | const nav = node("nav", {}, "", this);
1669 | button({ icon: "artist" }, "Artists and albums", nav).addEventListener("click", (_) => this.pushState({ type: "tags", tag: "AlbumArtist" }));
1670 | button({ icon: "music" }, "Genres", nav).addEventListener("click", (_) => this.pushState({ type: "tags", tag: "Genre" }));
1671 | button({ icon: "folder" }, "Files and directories", nav).addEventListener("click", (_) => this.pushState({ type: "path", path: "" }));
1672 | button({ icon: "magnify" }, "Search", nav).addEventListener("click", (_) => this.pushState({ type: "search" }));
1673 | }
1674 | pushState(state) {
1675 | this.selection.clear();
1676 | this.stateStack.push(state);
1677 | this.showState(state);
1678 | }
1679 | showState(state) {
1680 | switch (state.type) {
1681 | case "tags":
1682 | this.listTags(state.tag, state.filter);
1683 | break;
1684 | case "songs":
1685 | this.listSongs(state.filter);
1686 | break;
1687 | case "path":
1688 | this.listPath(state.path);
1689 | break;
1690 | case "search":
1691 | this.showSearch(state.query);
1692 | break;
1693 | }
1694 | }
1695 | async listTags(tag, filter = {}) {
1696 | const values = (await this.mpd.listTags(tag, filter)).filter(nonempty);
1697 | clear(this);
1698 | if ("AlbumArtist" in filter || "Genre" in filter) {
1699 | this.buildBack();
1700 | }
1701 | values.length > 0 && this.addFilter();
1702 | let nodes = values.map((value) => this.buildTag(tag, value, filter));
1703 | this.append(...nodes);
1704 | let albumNodes = nodes.filter((node2) => node2.type == "Album");
1705 | this.configureSelection(albumNodes);
1706 | }
1707 | async listPath(path) {
1708 | let paths = await this.mpd.listPath(path);
1709 | clear(this);
1710 | path && this.buildBack();
1711 | paths["directory"].length + paths["file"].length > 0 && this.addFilter();
1712 | let items = [...paths["directory"], ...paths["file"]];
1713 | let nodes = items.map((data) => {
1714 | let node2 = new Path(data);
1715 | if (data.directory) {
1716 | const path2 = data.directory;
1717 | node2.addButton("chevron-double-right", () => this.pushState({ type: "path", path: path2 }));
1718 | }
1719 | return node2;
1720 | });
1721 | this.append(...nodes);
1722 | this.configureSelection(nodes);
1723 | }
1724 | async listSongs(filter) {
1725 | const songs = await this.mpd.listSongs(filter);
1726 | clear(this);
1727 | this.buildBack();
1728 | songs.length > 0 && this.addFilter();
1729 | let nodes = songs.map((song) => new Song(song));
1730 | this.append(...nodes);
1731 | this.configureSelection(nodes);
1732 | }
1733 | showSearch(query = "") {
1734 | clear(this);
1735 | this.append(this.search);
1736 | this.search.value = query;
1737 | this.search.focus();
1738 | query && this.search.onSubmit();
1739 | }
1740 | async doSearch(query) {
1741 | this.stateStack[this.stateStack.length - 1] = {
1742 | type: "search",
1743 | query
1744 | };
1745 | clear(this);
1746 | this.append(this.search);
1747 | this.search.pending(true);
1748 | const songs1 = await this.mpd.searchSongs({ "AlbumArtist": query });
1749 | const songs2 = await this.mpd.searchSongs({ "Album": query });
1750 | const songs3 = await this.mpd.searchSongs({ "Title": query });
1751 | this.search.pending(false);
1752 | let nodes1 = this.aggregateSearch(songs1, "AlbumArtist");
1753 | let nodes2 = this.aggregateSearch(songs2, "Album");
1754 | let nodes3 = songs3.map((song) => new Song(song));
1755 | this.append(...nodes1, ...nodes2, ...nodes3);
1756 | let selectableNodes = [...nodes2, ...nodes3];
1757 | this.configureSelection(selectableNodes);
1758 | }
1759 | aggregateSearch(songs, tag) {
1760 | let results = /* @__PURE__ */ new Map();
1761 | let nodes = [];
1762 | songs.forEach((song) => {
1763 | let filter = {}, value;
1764 | const artist = song.AlbumArtist || song.Artist;
1765 | if (tag == "Album") {
1766 | value = song[tag];
1767 | if (artist) {
1768 | filter["AlbumArtist"] = artist;
1769 | }
1770 | }
1771 | if (tag == "AlbumArtist") {
1772 | value = artist;
1773 | }
1774 | results.set(value, filter);
1775 | });
1776 | results.forEach((filter, value) => {
1777 | let node2 = this.buildTag(tag, value, filter);
1778 | nodes.push(node2);
1779 | });
1780 | return nodes;
1781 | }
1782 | buildTag(tag, value, filter) {
1783 | let node2 = new Tag(tag, value, filter);
1784 | node2.fillArt(this.mpd);
1785 | switch (tag) {
1786 | case "AlbumArtist":
1787 | case "Genre":
1788 | node2.onclick = () => this.pushState({ type: "tags", tag: "Album", filter: node2.createChildFilter() });
1789 | break;
1790 | case "Album":
1791 | node2.addButton("chevron-double-right", () => this.pushState({ type: "songs", filter: node2.createChildFilter() }));
1792 | break;
1793 | }
1794 | return node2;
1795 | }
1796 | buildBack() {
1797 | const backState = this.stateStack[this.stateStack.length - 2];
1798 | let title = "";
1799 | switch (backState.type) {
1800 | case "path":
1801 | title = "..";
1802 | break;
1803 | case "search":
1804 | title = "Search";
1805 | break;
1806 | case "tags":
1807 | title = TAGS[backState.tag];
1808 | break;
1809 | }
1810 | const node2 = new Back(title);
1811 | this.append(node2);
1812 | node2.onclick = () => this.popState();
1813 | }
1814 | addFilter() {
1815 | this.append(this.filter);
1816 | this.filter.value = "";
1817 | }
1818 | configureSelection(items) {
1819 | const { selection, mpd } = this;
1820 | let commands = [{
1821 | cb: async (items2) => {
1822 | const commands2 = ["clear", ...items2.map(createEnqueueCommand), "play"];
1823 | await mpd.command(commands2);
1824 | selection.clear();
1825 | },
1826 | label: "Play",
1827 | icon: "play"
1828 | }, {
1829 | cb: async (items2) => {
1830 | const commands2 = items2.map(createEnqueueCommand);
1831 | await mpd.command(commands2);
1832 | selection.clear();
1833 | },
1834 | label: "Enqueue",
1835 | icon: "plus"
1836 | }];
1837 | selection.configure(items, "multi", commands);
1838 | }
1839 | };
1840 | customElements.define("cyp-library", Library);
1841 | function nonempty(str) {
1842 | return str.length > 0;
1843 | }
1844 | function createEnqueueCommand(node2) {
1845 | if (node2 instanceof Song || node2 instanceof Path) {
1846 | return `add "${escape(node2.file)}"`;
1847 | } else if (node2 instanceof Tag) {
1848 | return [
1849 | "findadd",
1850 | serializeFilter(node2.createChildFilter())
1851 | // `sort ${SORT}` // MPD >= 0.22, not yet released
1852 | ].join(" ");
1853 | } else {
1854 | throw new Error(`Cannot create enqueue command for "${node2.nodeName}"`);
1855 | }
1856 | }
1857 | })();
1858 |
--------------------------------------------------------------------------------
/app/font/LatoLatin-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ondras/cyp/abb70c825477585bd3ae5a82158d1c139f5aefbf/app/font/LatoLatin-Bold.woff2
--------------------------------------------------------------------------------
/app/font/LatoLatin-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ondras/cyp/abb70c825477585bd3ae5a82158d1c139f5aefbf/app/font/LatoLatin-Regular.woff2
--------------------------------------------------------------------------------
/app/icons/album.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/arrow-down-bold.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/arrow-up-bold.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/artist.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/cancel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/checkbox-marked-outline.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/chevron-double-right.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/close.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/content-save.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/delete.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/download.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/fast-forward.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/filter-variant.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/folder.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/keyboard-backspace.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/library-music.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/magnify.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/minus_unused.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/music.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/pause.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/play.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/playlist-music.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/plus.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/repeat.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/rewind.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/settings.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/shuffle.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/volume-high.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/volume-off.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Control Your Player
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | - Theme
51 | -
52 |
57 |
58 | - Color
59 | -
60 |
64 |
68 |
72 |
73 | - YouTube results
74 | -
75 |
81 |
82 |
83 |
84 |
85 |
99 |
100 |
101 |
102 |
103 |
104 |
--------------------------------------------------------------------------------
/app/js/art.ts:
--------------------------------------------------------------------------------
1 | import * as html from "./html.js";
2 | import * as conf from "./conf.js";
3 | import MPD from "./mpd.js";
4 |
5 |
6 | const cache: Record> = {};
7 | const MIME = "image/jpeg";
8 | const STORAGE_PREFIX = `art-${conf.artSize}` ;
9 |
10 | function store(key: string, data: string) {
11 | localStorage.setItem(`${STORAGE_PREFIX}-${key}`, data);
12 | }
13 |
14 | function load(key: string) {
15 | return localStorage.getItem(`${STORAGE_PREFIX}-${key}`);
16 | }
17 |
18 | async function bytesToImage(bytes: Uint8Array) {
19 | const blob = new Blob([bytes]);
20 | const src = URL.createObjectURL(blob);
21 | const image = html.node("img", {src});
22 | return new Promise(resolve => {
23 | image.onload = () => resolve(image);
24 | });
25 | }
26 |
27 | function resize(image: HTMLImageElement | HTMLCanvasElement) {
28 | while (Math.min(image.width, image.height) >= 2*conf.artSize) {
29 | let tmp = html.node("canvas", {width:image.width/2, height:image.height/2});
30 | tmp.getContext("2d")!.drawImage(image, 0, 0, tmp.width, tmp.height);
31 | image = tmp;
32 | }
33 | const canvas = html.node("canvas", {width:conf.artSize, height:conf.artSize});
34 | canvas.getContext("2d")!.drawImage(image, 0, 0, canvas.width, canvas.height);
35 | return canvas;
36 | }
37 |
38 | export async function get(mpd: MPD, artist: string, album: string, songUrl?: string): Promise {
39 | const key = `${artist}-${album}`;
40 | if (key in cache) { return cache[key]; }
41 |
42 | const loaded = load(key);
43 | if (loaded) {
44 | cache[key] = loaded;
45 | return loaded;
46 | }
47 |
48 | if (!songUrl) { return null; }
49 |
50 | // promise to be returned in the meantime
51 | let resolve;
52 | const promise = new Promise(res => resolve = res);
53 | cache[key] = promise;
54 |
55 | const data = await mpd.albumArt(songUrl);
56 | if (data) {
57 | const bytes = new Uint8Array(data);
58 | const image = await bytesToImage(bytes);
59 | const url = resize(image).toDataURL(MIME);
60 | store(key, url);
61 | cache[key] = url;
62 | resolve(url);
63 | } else {
64 | cache[key] = null;
65 | }
66 | return cache[key];
67 | }
68 |
--------------------------------------------------------------------------------
/app/js/component.ts:
--------------------------------------------------------------------------------
1 | import App from "./elements/app.js";
2 |
3 |
4 | export default class Component extends HTMLElement {
5 | connectedCallback() {
6 | const { app } = this;
7 |
8 | app.addEventListener("load", _ => this.onAppLoad());
9 | app.addEventListener("component-change", _ => {
10 | const component = app.component;
11 | const isThis = (this.nodeName.toLowerCase() == `cyp-${component}`);
12 | this.onComponentChange(component, isThis);
13 | });
14 | }
15 |
16 | protected get app() { return this.closest("cyp-app")!; }
17 | protected get mpd() { return this.app.mpd; }
18 |
19 | protected onAppLoad() {}
20 | protected onComponentChange(component: string, isThis: boolean) {}
21 | }
22 |
--------------------------------------------------------------------------------
/app/js/conf.ts:
--------------------------------------------------------------------------------
1 | export const artSize = 96 * (window.devicePixelRatio || 1);
2 | export const ytPath = "_youtube";
3 | export let ytLimit = 3;
4 |
5 | export function setYtLimit(limit: number) { ytLimit = limit; }
6 |
--------------------------------------------------------------------------------
/app/js/cyp.ts:
--------------------------------------------------------------------------------
1 | import "./elements/range.js";
2 | import "./elements/app.js";
3 | import "./elements/menu.js";
4 | import "./elements/player.js";
5 | import "./elements/queue.js";
6 | import "./elements/playlists.js";
7 | import "./elements/settings.js";
8 | import "./elements/yt.js";
9 | import "./elements/song.js";
10 | import "./elements/library.js";
11 | import "./elements/tag.js";
12 | import "./elements/back.js";
13 | import "./elements/path.js";
14 |
--------------------------------------------------------------------------------
/app/js/elements/app.ts:
--------------------------------------------------------------------------------
1 | import MPD from "../mpd.js";
2 | import * as html from "../html.js";
3 | import Selection from "../selection.js";
4 |
5 |
6 | function initIcons() {
7 | [...document.querySelectorAll("[data-icon]")].forEach(node => {
8 | node.dataset.icon!.split(" ").forEach(name => {
9 | let icon = html.icon(name);
10 | node.prepend(icon);
11 | });
12 | });
13 | }
14 |
15 | export default class App extends HTMLElement {
16 | mpd!: MPD;
17 |
18 | static get observedAttributes() { return ["component"]; }
19 |
20 | constructor() {
21 | super();
22 | initIcons();
23 | }
24 |
25 | async connectedCallback() {
26 | await waitForChildren(this);
27 |
28 | window.addEventListener("hashchange", e => this.onHashChange());
29 | this.onHashChange();
30 |
31 | await this.connect();
32 | this.dispatchEvent(new CustomEvent("load"));
33 |
34 | this.initMediaHandler();
35 | }
36 |
37 | attributeChangedCallback(name: string, oldValue: string, newValue: string) {
38 | switch (name) {
39 | case "component":
40 | location.hash = newValue;
41 | const e = new CustomEvent("component-change");
42 | this.dispatchEvent(e);
43 | break;
44 | }
45 | }
46 |
47 | get component() { return this.getAttribute("component") || ""; }
48 | set component(component) { this.setAttribute("component", component); }
49 |
50 | createSelection() {
51 | let selection = new Selection();
52 | this.querySelector("footer")!.append(selection.commands);
53 | return selection;
54 | }
55 |
56 | protected onHashChange() {
57 | const component = location.hash.substring(1) || "queue";
58 | if (component != this.component) { this.component = component; }
59 | }
60 |
61 | protected onChange(changed: string[]) { this.dispatchEvent(new CustomEvent("idle-change", {detail:changed})); }
62 |
63 | protected async onClose(e: CloseEvent) {
64 | await sleep(3000);
65 | this.connect();
66 | }
67 |
68 | protected async connect() {
69 | const attempts = 3;
70 | for (let i=0;i this.onChange(changed);
74 | mpd.onClose = e => this.onClose(e);
75 | this.mpd = mpd;
76 | return;
77 | } catch (e) {
78 | await sleep(500);
79 | }
80 | }
81 | alert(`Failed to connect to MPD after ${attempts} attempts. Please reload the page to try again.`);
82 | }
83 |
84 | protected initMediaHandler() {
85 | // check support mediaSession
86 | if (!('mediaSession' in navigator)) {
87 | console.log('mediaSession is not supported');
88 | return;
89 | }
90 |
91 | // DOM (using media session controls are allowed only if there is audio/video tag)
92 | const audio = html.node("audio", {loop: true}, "", this);
93 | html.node("source", {src: 'https://raw.githubusercontent.com/anars/blank-audio/master/10-seconds-of-silence.mp3'}, '', audio);
94 |
95 | // Init event session (play audio) on click (because restrictions by web browsers)
96 | window.addEventListener("click", () => {
97 | audio.play();
98 | }, {once: true});
99 |
100 | // mediaSession define metadata
101 | navigator.mediaSession.metadata = new MediaMetadata({
102 | title: 'Control Your Player'
103 | });
104 |
105 | // mediaSession define action handlers
106 | navigator.mediaSession.setActionHandler('play', () => {
107 | this.mpd.command("play")
108 | audio.play()
109 | });
110 | navigator.mediaSession.setActionHandler('pause', () => {
111 | this.mpd.command("pause 1")
112 | audio.pause()
113 | });
114 | navigator.mediaSession.setActionHandler('previoustrack', () => {
115 | this.mpd.command("previous")
116 | audio.play()
117 | });
118 | navigator.mediaSession.setActionHandler('nexttrack', () => {
119 | this.mpd.command("next")
120 | audio.play()
121 | });
122 | }
123 | }
124 |
125 | customElements.define("cyp-app", App);
126 |
127 | function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); }
128 |
129 | function waitForChildren(app: App) {
130 | const children = [...app.querySelectorAll("*")];
131 | const names = children.map(node => node.nodeName.toLowerCase())
132 | .filter(name => name.startsWith("cyp-"));
133 | const unique = new Set(names);
134 |
135 | const promises = [...unique].map(name => customElements.whenDefined(name));
136 | return Promise.all(promises);
137 | }
138 |
--------------------------------------------------------------------------------
/app/js/elements/back.ts:
--------------------------------------------------------------------------------
1 | import Item from "../item.js";
2 | import * as html from "../html.js";
3 |
4 |
5 | export default class Back extends Item {
6 | constructor(title: string) {
7 | super();
8 | this.append(html.icon("keyboard-backspace"));
9 | this.buildTitle(title);
10 | }
11 | }
12 |
13 | customElements.define("cyp-back", Back);
14 |
--------------------------------------------------------------------------------
/app/js/elements/filter.ts:
--------------------------------------------------------------------------------
1 | import * as html from "../html.js";
2 | import Item from "../item.js";
3 |
4 |
5 | const SELECTOR = ["cyp-tag", "cyp-path", "cyp-song"].join(", ");
6 |
7 | export default class Filter extends HTMLElement {
8 | constructor() {
9 | super()
10 |
11 | html.node("input", {type:"text"}, "", this);
12 | html.icon("filter-variant", this);
13 |
14 | this.input.addEventListener("input", e => this.apply());
15 | }
16 |
17 | get value() { return this.input.value.trim(); }
18 | set value(value) { this.input.value = value; }
19 | protected get input() { return this.querySelector("input")!; }
20 |
21 | protected apply() {
22 | let value = this.value.toLowerCase();
23 | let all = [...this.parentNode!.querySelectorAll- (SELECTOR)];
24 | all.forEach(item => item.hidden = !item.matchPrefix(value));
25 | }
26 | }
27 |
28 | customElements.define("cyp-filter", Filter);
29 |
--------------------------------------------------------------------------------
/app/js/elements/library.ts:
--------------------------------------------------------------------------------
1 | import * as html from "../html.js";
2 | import Component from "../component.js";
3 | import Tag, { TagType, TagFilter } from "./tag.js";
4 | import Path from "./path.js";
5 | import Back from "./back.js";
6 | import Song from "./song.js";
7 | import Search from "./search.js";
8 | import Filter from "./filter.js";
9 | import Selection from "../selection.js";
10 | import { escape, serializeFilter } from "../mpd.js";
11 | import { SongData, PathData } from "../parser.js";
12 |
13 |
14 |
15 | type OurNode = Song | Path | Tag;
16 |
17 | type State = {
18 | type: "tags";
19 | tag: TagType;
20 | filter?: TagFilter;
21 | } | {
22 | type: "songs";
23 | filter: TagFilter;
24 | } | {
25 | type: "path";
26 | path: string;
27 | } | {
28 | type: "search";
29 | query?: string;
30 | }
31 |
32 | const SORT = "-Track";
33 | const TAGS = {
34 | "Album": "Albums",
35 | "AlbumArtist": "Artists",
36 | "Genre": "Genres"
37 | }
38 |
39 | class Library extends Component {
40 | protected selection!: Selection;
41 | protected search = new Search();
42 | protected filter = new Filter();
43 | protected stateStack: State[] = [];
44 |
45 | constructor() {
46 | super();
47 |
48 | this.search.onSubmit = () => {
49 | let query = this.search.value;
50 | if (query.length < 3) { return; }
51 | this.doSearch(query);
52 | }
53 | }
54 |
55 | protected popState() {
56 | this.selection.clear();
57 | this.stateStack.pop();
58 |
59 | if (this.stateStack.length > 0) {
60 | let state = this.stateStack[this.stateStack.length-1];
61 | this.showState(state);
62 | } else {
63 | this.showRoot();
64 | }
65 | }
66 |
67 | protected onAppLoad() {
68 | this.selection = this.app.createSelection();
69 | this.showRoot();
70 | }
71 |
72 | protected onComponentChange(c: string, isThis: boolean) {
73 | const wasHidden = this.hidden;
74 | this.hidden = !isThis;
75 |
76 | if (!wasHidden && isThis) { this.showRoot(); }
77 | }
78 |
79 | protected showRoot() {
80 | this.stateStack = [];
81 | html.clear(this);
82 |
83 | const nav = html.node("nav", {}, "", this);
84 |
85 | html.button({icon:"artist"}, "Artists and albums", nav)
86 | .addEventListener("click", _ => this.pushState({type:"tags", tag:"AlbumArtist"}));
87 |
88 | html.button({icon:"music"}, "Genres", nav)
89 | .addEventListener("click", _ => this.pushState({type:"tags", tag:"Genre"}));
90 |
91 | html.button({icon:"folder"}, "Files and directories", nav)
92 | .addEventListener("click", _ => this.pushState({type:"path", path:""}));
93 |
94 | html.button({icon:"magnify"}, "Search", nav)
95 | .addEventListener("click", _ => this.pushState({type:"search"}));
96 | }
97 |
98 | protected pushState(state: State) {
99 | this.selection.clear();
100 | this.stateStack.push(state);
101 |
102 | this.showState(state);
103 | }
104 |
105 | protected showState(state: State) {
106 | switch (state.type) {
107 | case "tags": this.listTags(state.tag, state.filter); break;
108 | case "songs": this.listSongs(state.filter); break;
109 | case "path": this.listPath(state.path); break;
110 | case "search": this.showSearch(state.query); break;
111 | }
112 | }
113 |
114 | protected async listTags(tag: TagType, filter = {}) {
115 | const values = (await this.mpd.listTags(tag, filter)).filter(nonempty) as any[];
116 | html.clear(this);
117 |
118 | if ("AlbumArtist" in filter || "Genre" in filter) { this.buildBack(); }
119 | (values.length > 0) && this.addFilter();
120 |
121 | let nodes = values.map(value => this.buildTag(tag, value, filter));
122 | this.append(...nodes);
123 |
124 | let albumNodes = nodes.filter(node => node.type == "Album");
125 | this.configureSelection(albumNodes);
126 | }
127 |
128 | protected async listPath(path: string) {
129 | let paths = await this.mpd.listPath(path);
130 | html.clear(this);
131 |
132 | path && this.buildBack();
133 | (paths["directory"].length + paths["file"].length > 0) && this.addFilter();
134 |
135 | let items = [...paths["directory"], ...paths["file"]];
136 | let nodes = items.map(data => {
137 | let node = new Path(data);
138 |
139 | if (data.directory) {
140 | const path = data.directory;
141 | node.addButton("chevron-double-right", () => this.pushState({type:"path", path}));
142 | }
143 |
144 | return node;
145 | });
146 | this.append(...nodes);
147 |
148 | this.configureSelection(nodes);
149 | }
150 |
151 | protected async listSongs(filter: TagFilter) {
152 | const songs = await this.mpd.listSongs(filter);
153 | html.clear(this);
154 | this.buildBack();
155 | (songs.length > 0 && this.addFilter());
156 |
157 | let nodes = songs.map(song => new Song(song));
158 | this.append(...nodes);
159 |
160 | this.configureSelection(nodes);
161 | }
162 |
163 | protected showSearch(query = "") {
164 | html.clear(this);
165 |
166 | this.append(this.search);
167 | this.search.value = query;
168 | this.search.focus();
169 |
170 | query && this.search.onSubmit();
171 | }
172 |
173 | protected async doSearch(query: string) {
174 | this.stateStack[this.stateStack.length-1] = {
175 | type: "search",
176 | query
177 | }
178 |
179 | html.clear(this);
180 | this.append(this.search);
181 | this.search.pending(true);
182 |
183 | const songs1 = await this.mpd.searchSongs({"AlbumArtist": query});
184 | const songs2 = await this.mpd.searchSongs({"Album": query});
185 | const songs3 = await this.mpd.searchSongs({"Title": query});
186 |
187 | this.search.pending(false);
188 |
189 | let nodes1 = this.aggregateSearch(songs1, "AlbumArtist");
190 | let nodes2 = this.aggregateSearch(songs2, "Album");
191 | let nodes3 = songs3.map(song => new Song(song));
192 | this.append(...nodes1, ...nodes2, ...nodes3);
193 |
194 | let selectableNodes = [...nodes2, ...nodes3];
195 | this.configureSelection(selectableNodes);
196 | }
197 |
198 | protected aggregateSearch(songs: SongData[], tag: TagType) {
199 | let results = new Map();
200 | let nodes: OurNode[] = [];
201 |
202 | songs.forEach(song => {
203 | let filter: TagFilter = {}, value;
204 | const artist = song.AlbumArtist || song.Artist;
205 |
206 | if (tag == "Album") {
207 | value = song[tag];
208 | if (artist) { filter["AlbumArtist"] = artist; }
209 | }
210 |
211 | if (tag == "AlbumArtist") { value = artist; }
212 |
213 | results.set(value, filter);
214 | });
215 |
216 | results.forEach((filter, value) => {
217 | let node = this.buildTag(tag, value, filter);
218 | nodes.push(node);
219 | });
220 |
221 | return nodes;
222 | }
223 |
224 | protected buildTag(tag: TagType, value: string, filter: TagFilter) {
225 | let node = new Tag(tag, value, filter);
226 | node.fillArt(this.mpd);
227 |
228 | switch (tag) {
229 | case "AlbumArtist":
230 | case "Genre":
231 | node.onclick = () => this.pushState({type:"tags", tag:"Album", filter:node.createChildFilter()});
232 | break;
233 |
234 | case "Album":
235 | node.addButton("chevron-double-right", () => this.pushState({type:"songs", filter:node.createChildFilter()}));
236 | break;
237 | }
238 |
239 | return node;
240 | }
241 |
242 | protected buildBack() {
243 | const backState = this.stateStack[this.stateStack.length-2];
244 | let title = "";
245 | switch (backState.type) {
246 | case "path": title = ".."; break;
247 | case "search": title = "Search"; break;
248 | case "tags": title = TAGS[backState.tag]; break;
249 | }
250 |
251 | const node = new Back(title);
252 | this.append(node);
253 | node.onclick = () => this.popState();
254 | }
255 |
256 | protected addFilter() {
257 | this.append(this.filter);
258 | this.filter.value = "";
259 | }
260 |
261 | configureSelection(items: OurNode[]) {
262 | const { selection, mpd } = this;
263 |
264 | let commands = [{
265 | cb: async (items: OurNode[]) => {
266 | const commands = ["clear", ...items.map(createEnqueueCommand), "play"];
267 | await mpd.command(commands);
268 | selection.clear(); // fixme notification?
269 | },
270 | label:"Play",
271 | icon:"play"
272 | }, {
273 | cb: async (items: OurNode[]) => {
274 | const commands = items.map(createEnqueueCommand);
275 | await mpd.command(commands);
276 | selection.clear(); // fixme notification?
277 | },
278 | label:"Enqueue",
279 | icon:"plus"
280 | }];
281 |
282 | selection.configure(items, "multi", commands);
283 | }
284 | }
285 |
286 | customElements.define("cyp-library", Library);
287 |
288 | function nonempty(str: string) { return (str.length > 0); }
289 |
290 | function createEnqueueCommand(node: OurNode | HTMLElement) {
291 | if (node instanceof Song || node instanceof Path) {
292 | return `add "${escape(node.file)}"`;
293 | } else if (node instanceof Tag) {
294 | return [
295 | "findadd",
296 | serializeFilter(node.createChildFilter()),
297 | // `sort ${SORT}` // MPD >= 0.22, not yet released
298 | ].join(" ");
299 | } else {
300 | throw new Error(`Cannot create enqueue command for "${node.nodeName}"`);
301 | }
302 | }
303 |
--------------------------------------------------------------------------------
/app/js/elements/menu.ts:
--------------------------------------------------------------------------------
1 | import Component from "../component.js";
2 |
3 |
4 | class Menu extends Component {
5 | protected tabs = Array.from(this.querySelectorAll("[data-for]"));
6 |
7 | connectedCallback() {
8 | super.connectedCallback();
9 |
10 | this.tabs.forEach(tab => {
11 | tab.addEventListener("click", _ => this.app.setAttribute("component", tab.dataset.for!));
12 | });
13 | }
14 |
15 | protected onAppLoad() {
16 | this.app.addEventListener("queue-length-change", e => {
17 | this.querySelector(".queue-length")!.textContent = `(${(e as CustomEvent).detail})`;
18 | });
19 | }
20 |
21 | protected onComponentChange(component: string) {
22 | this.tabs.forEach(tab => {
23 | tab.classList.toggle("active", tab.dataset.for == component);
24 | });
25 | }
26 | }
27 |
28 | customElements.define("cyp-menu", Menu);
29 |
--------------------------------------------------------------------------------
/app/js/elements/path.ts:
--------------------------------------------------------------------------------
1 | import Item from "../item.js";
2 | import * as html from "../html.js";
3 | import * as format from "../format.js";
4 | import { PathData } from "../parser.js";
5 |
6 |
7 | export default class Path extends Item {
8 | protected isDirectory: boolean;
9 |
10 | constructor(protected data: PathData) {
11 | super();
12 | this.isDirectory = ("directory" in this.data);
13 | this.append(html.icon(this.isDirectory ? "folder" : "music"));
14 | this.buildTitle(format.fileName(this.file));
15 | }
16 |
17 | get file() { return (this.isDirectory ? this.data.directory : this.data.file) as string; }
18 | }
19 |
20 | customElements.define("cyp-path", Path);
21 |
--------------------------------------------------------------------------------
/app/js/elements/player.ts:
--------------------------------------------------------------------------------
1 | import * as art from "../art.js";
2 | import * as html from "../html.js";
3 | import * as format from "../format.js";
4 | import Component from "../component.js";
5 | import { SongData, StatusData } from "../parser.js";
6 |
7 |
8 | const ELAPSED_PERIOD = 500;
9 |
10 | class Player extends Component {
11 | protected current = {
12 | song: {} as SongData,
13 | elapsed: 0,
14 | at: 0,
15 | volume: 0
16 | }
17 | protected toggleVolume = 0;
18 | protected DOM: Record;
19 |
20 | constructor() {
21 | super();
22 |
23 | const DOM: Record = {};
24 | const all = this.querySelectorAll("[class]");
25 | [...all].forEach(node => DOM[node.className] = node);
26 | DOM.progress = DOM.timeline.querySelector("x-range")!;
27 | DOM.volume = DOM.volume.querySelector("x-range")!;
28 |
29 | this.DOM = DOM;
30 | }
31 |
32 | handleEvent(e: CustomEvent) {
33 | switch (e.type) {
34 | case "idle-change":
35 | let hasOptions = e.detail.includes("options");
36 | let hasPlayer = e.detail.includes("player");
37 | let hasMixer = e.detail.includes("mixer");
38 | (hasOptions || hasPlayer || hasMixer) && this.updateStatus();
39 | hasPlayer && this.updateCurrent();
40 | break;
41 | }
42 | }
43 |
44 | protected onAppLoad() {
45 | this.addEvents();
46 | this.updateStatus();
47 | this.updateCurrent();
48 | this.app.addEventListener("idle-change", this);
49 |
50 | setInterval(() => this.updateElapsed(), ELAPSED_PERIOD);
51 | }
52 |
53 | protected async updateStatus() {
54 | const { current, mpd } = this;
55 | const data = await mpd.status();
56 |
57 | this.updateFlags(data);
58 | this.updateVolume(data);
59 |
60 | // rebase the time sync
61 | current.elapsed = Number(data.elapsed || 0);
62 | current.at = performance.now();
63 | }
64 |
65 | protected async updateCurrent() {
66 | const { current, mpd, DOM } = this;
67 | const data = await mpd.currentSong();
68 |
69 | if (data.file != current.song.file) { // changed song
70 | if (data.file) { // is there a song at all?
71 | DOM.title.textContent = data.Title || format.fileName(data.file);
72 | DOM.subtitle.textContent = format.subtitle(data, {duration:false});
73 |
74 | let duration = Number(data.duration);
75 | DOM.duration.textContent = format.time(duration);
76 | (DOM.progress as HTMLInputElement).max = String(duration);
77 | (DOM.progress as HTMLInputElement).disabled = false;
78 | } else {
79 | DOM.title.textContent = "";
80 | DOM.subtitle.textContent = "";
81 | (DOM.progress as HTMLInputElement).value = "0";
82 | (DOM.progress as HTMLInputElement).disabled = true;
83 | }
84 |
85 | this.dispatchSongChange(data);
86 | }
87 |
88 | let artistNew = data.Artist || data.AlbumArtist || "";
89 | let artistOld = current.song["Artist"] || current.song["AlbumArtist"];
90 | let albumNew = data["Album"];
91 | let albumOld = current.song["Album"];
92 |
93 | Object.assign(current.song, data);
94 |
95 | if (artistNew != artistOld || albumNew != albumOld) { // changed album (art)
96 | html.clear(DOM.art);
97 | let src = await art.get(mpd, artistNew, data.Album || "", data.file);
98 | if (src) {
99 | html.node("img", {src}, "", DOM.art);
100 | } else {
101 | html.icon("music", DOM.art);
102 | }
103 | }
104 | }
105 |
106 | protected updateElapsed() {
107 | const { current, DOM } = this;
108 |
109 | let elapsed = 0;
110 | if (current.song["file"]) {
111 | elapsed = current.elapsed;
112 | if (this.dataset.state == "play") { elapsed += (performance.now() - current.at)/1000; }
113 | }
114 |
115 | let progress = DOM.progress as HTMLInputElement;
116 | progress.value = String(elapsed);
117 | DOM.elapsed.textContent = format.time(elapsed);
118 | this.app.style.setProperty("--progress", String(elapsed/Number(progress.max)));
119 | }
120 |
121 | protected updateFlags(data: StatusData) {
122 | let flags: string[] = [];
123 | if (data.random == "1") { flags.push("random"); }
124 | if (data.repeat == "1") { flags.push("repeat"); }
125 | if (data.volume === "0") { flags.push("mute"); } // strict, because volume might be missing
126 | this.dataset.flags = flags.join(" ");
127 | this.dataset.state = data["state"];
128 | }
129 |
130 | protected updateVolume(data: StatusData) {
131 | const { current, DOM } = this;
132 |
133 | if ("volume" in data) {
134 | let volume = Number(data.volume);
135 |
136 | (DOM.mute as HTMLInputElement).disabled = false;
137 | (DOM.volume as HTMLInputElement).disabled = false;
138 | (DOM.volume as HTMLInputElement).value = String(volume);
139 |
140 | if (volume == 0 && current.volume > 0) { this.toggleVolume = current.volume; } // muted
141 | if (volume > 0 && current.volume == 0) { this.toggleVolume = 0; } // restored
142 | current.volume = volume;
143 | } else {
144 | (DOM.mute as HTMLInputElement).disabled = true;
145 | (DOM.volume as HTMLInputElement).disabled = true;
146 | (DOM.volume as HTMLInputElement).value = String(50);
147 | }
148 | }
149 |
150 | protected addEvents() {
151 | const { current, mpd, DOM } = this;
152 |
153 | DOM.play.addEventListener("click", _ => mpd.command("play"));
154 | DOM.pause.addEventListener("click", _ => mpd.command("pause 1"));
155 | DOM.prev.addEventListener("click", _ => mpd.command("previous"));
156 | DOM.next.addEventListener("click", _ => mpd.command("next"));
157 |
158 | DOM.random.addEventListener("click", _ => {
159 | let isRandom = this.dataset.flags!.split(" ").includes("random");
160 | mpd.command(`random ${isRandom ? "0" : "1"}`);
161 | });
162 | DOM.repeat.addEventListener("click", _ => {
163 | let isRepeat = this.dataset.flags!.split(" ").includes("repeat");
164 | mpd.command(`repeat ${isRepeat ? "0" : "1"}`);
165 | });
166 |
167 | DOM.progress.addEventListener("input", e => {
168 | let elapsed = (e.target as HTMLInputElement).valueAsNumber;
169 | current.elapsed = elapsed;
170 | current.at = performance.now();
171 | mpd.command(`seekcur ${elapsed}`);
172 | });
173 |
174 | DOM.volume.addEventListener("input", e => mpd.command(`setvol ${(e.target as HTMLInputElement).valueAsNumber}`));
175 | DOM.mute.addEventListener("click", () => mpd.command(`setvol ${this.toggleVolume}`));
176 | }
177 |
178 | protected dispatchSongChange(detail: SongData) {
179 | const e = new CustomEvent("song-change", {detail});
180 | this.app.dispatchEvent(e);
181 | }
182 | }
183 |
184 | customElements.define("cyp-player", Player);
185 |
--------------------------------------------------------------------------------
/app/js/elements/playlist.ts:
--------------------------------------------------------------------------------
1 | import * as html from "../html.js";
2 | import Item from "../item.js";
3 |
4 |
5 | export default class Playlist extends Item {
6 | constructor(readonly name: string) {
7 | super();
8 | html.icon("playlist-music", this);
9 | this.buildTitle(name);
10 | }
11 | }
12 |
13 | customElements.define("cyp-playlist", Playlist);
14 |
--------------------------------------------------------------------------------
/app/js/elements/playlists.ts:
--------------------------------------------------------------------------------
1 | import * as html from "../html.js";
2 | import Component from "../component.js";
3 | import Playlist from "./playlist.js";
4 | import { escape } from "../mpd.js";
5 | import Song from "./song.js";
6 | import Back from "./back.js";
7 | import Selection from "../selection.js";
8 | import { SongData } from "../parser.js";
9 |
10 |
11 | class Playlists extends Component {
12 | protected selection!: Selection;
13 | protected current?: string;
14 |
15 | handleEvent(e: CustomEvent) {
16 | switch (e.type) {
17 | case "idle-change":
18 | e.detail.includes("stored_playlist") && this.sync();
19 | break;
20 | }
21 | }
22 |
23 | protected onAppLoad() {
24 | const { app } = this;
25 | this.selection = app.createSelection();
26 | app.addEventListener("idle-change", this);
27 | this.sync();
28 | }
29 |
30 | protected onComponentChange(c: string, isThis: boolean) {
31 | this.hidden = !isThis;
32 | }
33 |
34 | protected async sync() {
35 | if (this.current) {
36 | let songs = await this.mpd.listPlaylistItems(this.current);
37 | this.buildSongs(songs);
38 | } else {
39 | let lists = await this.mpd.listPlaylists();
40 | this.buildLists(lists);
41 | }
42 | }
43 |
44 | protected buildSongs(songs: SongData[]) {
45 | html.clear(this);
46 | this.buildBack();
47 |
48 | let nodes = songs.map(song => new Song(song));
49 | this.append(...nodes);
50 |
51 | this.configureSelectionSongs(nodes);
52 | }
53 |
54 | protected buildLists(lists: string[]) {
55 | html.clear(this);
56 |
57 | let playlists = lists.map(name => {
58 | let node = new Playlist(name);
59 | node.addButton("chevron-double-right", () => {
60 | this.current = name;
61 | this.sync();
62 | });
63 | return node;
64 | });
65 | this.append(...playlists);
66 |
67 | this.configureSelectionLists(playlists);
68 | }
69 |
70 | protected buildBack() {
71 | const node = new Back("Playlists");
72 | this.append(node);
73 | node.onclick = () => {
74 | this.current = undefined;
75 | this.sync();
76 | }
77 | }
78 |
79 | protected configureSelectionSongs(songs: Song[]) {
80 | const { selection, mpd } = this;
81 |
82 | let commands = [{
83 | cb: async (items: Song[]) => {
84 | await mpd.command(["clear", ...items.map(createAddCommand), "play"]);
85 | selection.clear(); // fixme notification?
86 | },
87 | label:"Play",
88 | icon:"play"
89 | }, {
90 | cb: async (items: Song[]) => {
91 | await mpd.command(items.map(createAddCommand));
92 | selection.clear(); // fixme notification?
93 | },
94 | label:"Enqueue",
95 | icon:"plus"
96 | }];
97 |
98 | selection.configure(songs, "multi", commands);
99 | }
100 |
101 | protected configureSelectionLists(lists: Playlist[]) {
102 | const { mpd, selection } = this;
103 |
104 | let commands = [{
105 | cb: async (item: Playlist) => {
106 | const name = item.name;
107 | const commands = ["clear", `load "${escape(name)}"`, "play"];
108 | await mpd.command(commands);
109 | selection.clear(); // fixme notification?
110 | },
111 | label:"Play",
112 | icon:"play"
113 | }, {
114 | cb: async (item: Playlist) => {
115 | const name = item.name;
116 | await mpd.command(`load "${escape(name)}"`);
117 | selection.clear(); // fixme notification?
118 | },
119 | label:"Enqueue",
120 | icon:"plus"
121 | }, {
122 | cb: async (item: Playlist) => {
123 | const name = item.name;
124 | if (!confirm(`Really delete playlist '${name}'?`)) { return; }
125 |
126 | await mpd.command(`rm "${escape(name)}"`);
127 | },
128 | label:"Delete",
129 | icon:"delete"
130 | }];
131 |
132 | selection.configure(lists, "single", commands);
133 | }
134 | }
135 |
136 | customElements.define("cyp-playlists", Playlists);
137 |
138 | function createAddCommand(node: Song) {
139 | return `add "${escape(node.file)}"`;
140 | }
141 |
--------------------------------------------------------------------------------
/app/js/elements/queue.ts:
--------------------------------------------------------------------------------
1 | import * as html from "../html.js";
2 | import Component from "../component.js";
3 | import Song from "./song.js";
4 | import { escape } from "../mpd.js";
5 | import Selection from "../selection.js";
6 | import { SongData } from "../parser.js";
7 |
8 |
9 | function generateMoveCommands(items: Song[], diff: number, parent: HTMLElement) {
10 | let all = [...parent.children].filter(node => node instanceof Song);
11 | const COMPARE = (a: Song, b: Song) => all.indexOf(a) - all.indexOf(b);
12 |
13 | return items.sort(COMPARE)
14 | .map(item => {
15 | let index = all.indexOf(item) + diff;
16 | if (index < 0 || index >= all.length) { return null; } // this does not move
17 | return `moveid ${item.songId} ${index}`;
18 | })
19 | .filter(command => command) as string[];
20 | }
21 |
22 | class Queue extends Component {
23 | protected selection!: Selection;
24 | protected currentId?: string;
25 |
26 | handleEvent(e: CustomEvent) {
27 | switch (e.type) {
28 | case "song-change":
29 | this.currentId = e.detail["Id"];
30 | this.updateCurrent();
31 | break;
32 |
33 | case "idle-change":
34 | e.detail.includes("playlist") && this.sync();
35 | break;
36 | }
37 | }
38 |
39 | protected onAppLoad() {
40 | const { app } = this;
41 | this.selection = app.createSelection();
42 | app.addEventListener("idle-change", this);
43 | app.addEventListener("song-change", this);
44 | this.sync();
45 | }
46 |
47 | protected onComponentChange(c: string, isThis: boolean) {
48 | this.hidden = !isThis;
49 | }
50 |
51 | protected async sync() {
52 | let songs = await this.mpd.listQueue();
53 | this.buildSongs(songs);
54 |
55 | let e = new CustomEvent("queue-length-change", {detail:songs.length});
56 | this.app.dispatchEvent(e);
57 | }
58 |
59 | protected updateCurrent() {
60 | let songs = [...this.children].filter(node => node instanceof Song) as Song[];
61 | songs.forEach(node => {
62 | node.playing = (node.songId == this.currentId);
63 | });
64 | }
65 |
66 | protected buildSongs(songs: SongData[]) {
67 | html.clear(this);
68 |
69 | let nodes = songs.map(song => {
70 | let node = new Song(song);
71 | node.addButton("play", async () => {
72 | await this.mpd.command(`playid ${node.songId}`);
73 | });
74 | return node;
75 | });
76 | this.append(...nodes);
77 |
78 | this.configureSelection(nodes);
79 | this.updateCurrent();
80 | }
81 |
82 | protected configureSelection(nodes: Song[]) {
83 | const { mpd, selection } = this;
84 |
85 | let commands = [{
86 | cb: (items: Song[]) => {
87 | const commands = generateMoveCommands(items, -1, this);
88 | mpd.command(commands);
89 | },
90 | label:"Up",
91 | icon:"arrow-up-bold"
92 | }, {
93 | cb: (items: Song[]) => {
94 | const commands = generateMoveCommands(items, +1, this);
95 | mpd.command(commands.reverse()); // move last first
96 | },
97 | label:"Down",
98 | icon:"arrow-down-bold"
99 | }, {
100 | cb: async (items: Song[]) => {
101 | let name = prompt("Save selected songs as a playlist?", "name");
102 | if (name === null) { return; }
103 |
104 | name = escape(name);
105 | try { // might not exist
106 | await mpd.command(`rm "${name}"`);
107 | } catch (e) {}
108 |
109 | const commands = items.map(item => {
110 | return `playlistadd "${name}" "${escape(item.file)}"`;
111 | });
112 | await mpd.command(commands);
113 |
114 | selection.clear();
115 | },
116 | label:"Save",
117 | icon:"content-save"
118 | }, {
119 | cb: async (items: Song[]) => {
120 | if (!confirm(`Remove these ${items.length} songs from the queue?`)) { return; }
121 |
122 | const commands = items.map(item => `deleteid ${item.songId}`);
123 | mpd.command(commands);
124 | },
125 | label:"Remove",
126 | icon:"delete"
127 | }];
128 |
129 | selection.configure(nodes, "multi", commands);
130 | }
131 | }
132 |
133 | customElements.define("cyp-queue", Queue);
134 |
--------------------------------------------------------------------------------
/app/js/elements/range.js:
--------------------------------------------------------------------------------
1 | ../../../node_modules/custom-range/range.js
--------------------------------------------------------------------------------
/app/js/elements/search.ts:
--------------------------------------------------------------------------------
1 | import * as html from "../html.js";
2 |
3 |
4 | export default class Search extends HTMLElement {
5 | constructor() {
6 | super();
7 |
8 | const form = html.node("form", {}, "", this);
9 | html.node("input", {type:"text"}, "", form);
10 | html.button({icon:"magnify"}, "", form);
11 |
12 | form.addEventListener("submit", e => {
13 | e.preventDefault();
14 | this.onSubmit();
15 | });
16 | }
17 |
18 | get value() { return this.input.value.trim(); }
19 | set value(value) { this.input.value = value; }
20 | protected get input() { return this.querySelector("input")!; }
21 |
22 | onSubmit() {}
23 | focus() { this.input.focus(); }
24 | pending(pending: boolean) { this.classList.toggle("pending", pending); }
25 | }
26 |
27 | customElements.define("cyp-search", Search);
28 |
--------------------------------------------------------------------------------
/app/js/elements/settings.ts:
--------------------------------------------------------------------------------
1 | import Component from "../component.js";
2 | import * as conf from "../conf.js";
3 |
4 |
5 | const prefix = "cyp";
6 |
7 | function loadFromStorage(key: string) {
8 | return localStorage.getItem(`${prefix}-${key}`);
9 | }
10 |
11 | function saveToStorage(key: string, value: string | number) {
12 | return localStorage.setItem(`${prefix}-${key}`, String(value));
13 | }
14 |
15 | class Settings extends Component {
16 | protected inputs = {
17 | theme: this.querySelector("[name=theme]")!,
18 | ytLimit: this.querySelector("[name=yt-limit]")!,
19 | color: [...this.querySelectorAll("[name=color]")]
20 | };
21 |
22 | protected onAppLoad() {
23 | const { inputs } = this;
24 |
25 | let mo = new MutationObserver(mrs => {
26 | mrs.forEach(mr => this._onAppAttributeChange(mr));
27 | });
28 | mo.observe(this.app, {attributes:true});
29 |
30 | inputs.theme.addEventListener("change", e => this.setTheme((e.target as HTMLSelectElement).value));
31 | inputs.ytLimit.addEventListener("change", e => this.setYtLimit(Number((e.target as HTMLSelectElement).value)));
32 | inputs.color.forEach(input => {
33 | input.addEventListener("click", e => this.setColor((e.target as HTMLInputElement).value));
34 | });
35 |
36 | const theme = loadFromStorage("theme");
37 | (theme ? this.app.setAttribute("theme", theme) : this.syncTheme());
38 |
39 | const color = loadFromStorage("color");
40 | (color ? this.app.setAttribute("color", color) : this.syncColor());
41 |
42 | const ytLimit = loadFromStorage("ytLimit") || conf.ytLimit;
43 | this.setYtLimit(Number(ytLimit));
44 | }
45 |
46 | _onAppAttributeChange(mr: MutationRecord) {
47 | if (mr.attributeName == "theme") { this.syncTheme(); }
48 | if (mr.attributeName == "color") { this.syncColor(); }
49 | }
50 |
51 | protected syncTheme() {
52 | this.inputs.theme.value = this.app.getAttribute("theme")!;
53 | }
54 |
55 | protected syncColor() {
56 | this.inputs.color.forEach(input => {
57 | input.checked = (input.value == this.app.getAttribute("color"));
58 | input.parentElement!.style.color = input.value;
59 | });
60 | }
61 |
62 | protected setTheme(theme: string) {
63 | saveToStorage("theme", theme);
64 | this.app.setAttribute("theme", theme);
65 | }
66 |
67 | protected setColor(color: string) {
68 | saveToStorage("color", color);
69 | this.app.setAttribute("color", color);
70 | }
71 |
72 | protected setYtLimit(ytLimit: number) {
73 | saveToStorage("ytLimit", ytLimit);
74 | conf.setYtLimit(ytLimit);
75 | }
76 |
77 | protected onComponentChange(c: string, isThis: boolean) {
78 | this.hidden = !isThis;
79 | }
80 | }
81 |
82 | customElements.define("cyp-settings", Settings);
83 |
--------------------------------------------------------------------------------
/app/js/elements/song.ts:
--------------------------------------------------------------------------------
1 | import * as format from "../format.js";
2 | import * as html from "../html.js";
3 | import Item from "../item.js";
4 | import { SongData } from "../parser.js";
5 |
6 |
7 | export default class Song extends Item {
8 | constructor(protected data: SongData) {
9 | super();
10 |
11 | html.icon("music", this);
12 | html.icon("play", this);
13 |
14 | const block = html.node("div", {className:"multiline"}, "", this);
15 |
16 | const title = this.buildSongTitle(data);
17 | block.append(title);
18 |
19 | if (data.Track) {
20 | const track = html.node("span", {className:"track"}, data.Track.padStart(2, "0"));
21 | title.insertBefore(html.text(" "), title.firstChild);
22 | title.insertBefore(track, title.firstChild);
23 | }
24 |
25 | if (data.Title) {
26 | const subtitle = format.subtitle(data);
27 | html.node("span", {className:"subtitle"}, subtitle, block);
28 | }
29 |
30 | this.playing = false;
31 | }
32 |
33 | get file() { return this.data.file; }
34 | get songId() { return this.data.Id; }
35 |
36 | set playing(playing: boolean) {
37 | this.classList.toggle("playing", playing);
38 | }
39 |
40 | protected buildSongTitle(data: SongData) {
41 | return super.buildTitle(data.Title || format.fileName(this.file));
42 | }
43 | }
44 |
45 | customElements.define("cyp-song", Song);
46 |
--------------------------------------------------------------------------------
/app/js/elements/tag.ts:
--------------------------------------------------------------------------------
1 | import * as html from "../html.js";
2 | import * as art from "../art.js";
3 | import Item from "../item.js";
4 | import MPD from "../mpd.js";
5 |
6 |
7 | const ICONS = {
8 | "AlbumArtist": "artist",
9 | "Album": "album",
10 | "Genre": "music"
11 | }
12 |
13 | export type TagType = "Album" | "AlbumArtist" | "Genre";
14 | export type TagFilter = Record;
15 |
16 |
17 | export default class Tag extends Item {
18 | constructor(readonly type: TagType, protected value: string, protected filter: TagFilter) {
19 | super();
20 | html.node("span", {className:"art"}, "", this);
21 | this.buildTitle(value);
22 | }
23 |
24 | createChildFilter(): TagFilter {
25 | return Object.assign({[this.type]:this.value}, this.filter);
26 | }
27 |
28 | async fillArt(mpd: MPD) {
29 | const parent = this.firstChild as HTMLElement;
30 | const filter = this.createChildFilter();
31 |
32 | let artist = filter["AlbumArtist"];
33 | let album = filter["Album"];
34 | let src = null;
35 |
36 | if (artist && album) {
37 | src = await art.get(mpd, artist, album);
38 | if (!src) {
39 | let songs = await mpd.listSongs(filter, [0,1]);
40 | if (songs.length) {
41 | src = await art.get(mpd, artist, album, songs[0]["file"]);
42 | }
43 | }
44 | }
45 |
46 | if (src) {
47 | html.node("img", {src}, "", parent);
48 | } else {
49 | const icon = ICONS[this.type];
50 | html.icon(icon, parent);
51 | }
52 | }
53 | }
54 |
55 | customElements.define("cyp-tag", Tag);
56 |
--------------------------------------------------------------------------------
/app/js/elements/yt-result.ts:
--------------------------------------------------------------------------------
1 | import Item from "../item.js";
2 | import * as html from "../html.js";
3 |
4 |
5 | export default class YtResult extends Item {
6 | constructor(title: string) {
7 | super()
8 | this.append(html.icon("magnify"));
9 | this.buildTitle(title);
10 | }
11 | }
12 |
13 | customElements.define("cyp-yt-result", YtResult);
14 |
--------------------------------------------------------------------------------
/app/js/elements/yt.ts:
--------------------------------------------------------------------------------
1 | import * as html from "../html.js";
2 | import * as conf from "../conf.js";
3 | import { escape } from "../mpd.js";
4 | import Component from "../component.js";
5 | import Search from "./search.js";
6 | import Result from "./yt-result.js";
7 |
8 |
9 | interface ResultData {
10 | title: string;
11 | id: string;
12 | }
13 |
14 | class YT extends Component {
15 | search = new Search();
16 |
17 | constructor() {
18 | super();
19 |
20 | this.search.onSubmit = () => {
21 | let query = this.search.value;
22 | query && this.doSearch(query);
23 | }
24 |
25 | this.clear();
26 | }
27 |
28 | protected clear() {
29 | html.clear(this);
30 | this.append(this.search);
31 | }
32 |
33 | protected async doSearch(query: string) {
34 | this.clear();
35 | this.search.pending(true);
36 |
37 | let url = `youtube?q=${encodeURIComponent(query)}&limit=${encodeURIComponent(conf.ytLimit)}`;
38 | let response = await fetch(url);
39 | if (response.status == 200) {
40 | let results = await response.json() as ResultData[];
41 | results.forEach(result => {
42 | let node = new Result(result.title);
43 | this.append(node);
44 | node.addButton("download", () => this.download(result.id));
45 | });
46 | } else {
47 | let text = await response.text();
48 | alert(text);
49 | }
50 |
51 | this.search.pending(false);
52 | }
53 |
54 | protected async download(id: string) {
55 | this.clear();
56 |
57 | let pre = html.node("pre", {}, "", this);
58 | this.search.pending(true);
59 |
60 | let body = new URLSearchParams();
61 | body.set("id", id);
62 | let response = await fetch("youtube", {method:"POST", body});
63 |
64 | let reader = response.body!.getReader();
65 | while (true) {
66 | let { done, value } = await reader.read();
67 | if (done) { break; }
68 | pre.textContent += decodeChunk(value!);
69 | pre.scrollTop = pre.scrollHeight;
70 | }
71 | reader.releaseLock();
72 |
73 | this.search.pending(false);
74 |
75 | if (response.status == 200) {
76 | this.mpd.command(`update ${escape(conf.ytPath)}`);
77 | }
78 | }
79 |
80 | protected onComponentChange(c: string, isThis: boolean) {
81 | const wasHidden = this.hidden;
82 | this.hidden = !isThis;
83 |
84 | if (!wasHidden && isThis) { this.clear(); }
85 | }
86 | }
87 |
88 | customElements.define("cyp-yt", YT);
89 |
90 | const decoder = new TextDecoder("utf-8");
91 | function decodeChunk(byteArray: Uint8Array) {
92 | // \r => \n
93 | return decoder.decode(byteArray).replace(/\u000d/g, "\n");
94 | }
95 |
--------------------------------------------------------------------------------
/app/js/format.ts:
--------------------------------------------------------------------------------
1 | import { SongData } from "./parser.js";
2 |
3 |
4 | export const SEPARATOR = " · ";
5 |
6 | export function time(sec: number) {
7 | sec = Math.round(sec);
8 | let m = Math.floor(sec / 60);
9 | let s = sec % 60;
10 | return `${m}:${s.toString().padStart(2, "0")}`;
11 | }
12 |
13 | export function subtitle(data: SongData, options = {duration:true}) {
14 | let tokens: string[] = [];
15 |
16 | if (data.Artist) {
17 | tokens.push(data.Artist);
18 | } else if (data.AlbumArtist) {
19 | tokens.push(data.AlbumArtist);
20 | }
21 |
22 | data.Album && tokens.push(data.Album);
23 | options.duration && data.duration && tokens.push(time(Number(data.duration)));
24 |
25 | return tokens.join(SEPARATOR);
26 | }
27 |
28 | export function fileName(file: string) {
29 | return file.split("/").pop() || "";
30 | }
31 |
--------------------------------------------------------------------------------
/app/js/html.ts:
--------------------------------------------------------------------------------
1 | import icons from "./icons.js";
2 |
3 |
4 | type Attrs = Record;
5 |
6 | export function node(name:T, attrs?: Attrs, content?: string, parent?: HTMLElement): HTMLElementTagNameMap[T] {
7 | let n = document.createElement(name);
8 | Object.assign(n, attrs);
9 |
10 | if (attrs && attrs.title) { n.setAttribute("aria-label", attrs.title as string); }
11 |
12 | content && text(content, n);
13 | parent && parent.append(n);
14 | return n;
15 | }
16 |
17 | export function icon(type: string, parent?: HTMLElement) {
18 | let str = icons[type];
19 | if (!str) {
20 | console.error("Bad icon type '%s'", type);
21 | return node("span", {}, "‽");
22 | }
23 |
24 | let tmp = node("div");
25 | tmp.innerHTML = str;
26 | let s = tmp.querySelector("svg");
27 | if (!s) { throw new Error(`Bad icon source for type '${type}'`); }
28 |
29 | s.classList.add("icon");
30 | s.classList.add(`icon-${type}`);
31 |
32 | parent && parent.append(s);
33 | return s;
34 | }
35 |
36 | export function button(attrs: Attrs, content?: string, parent?: HTMLElement) {
37 | let result = node("button", attrs, content, parent);
38 | if (attrs && attrs.icon) {
39 | let i = icon(attrs.icon as string);
40 | result.insertBefore(i, result.firstChild);
41 | }
42 | return result;
43 | }
44 |
45 | export function clear(node: HTMLElement) {
46 | while (node.firstChild) { node.firstChild.remove(); }
47 | return node;
48 | }
49 |
50 | export function text(txt: string, parent?: HTMLElement) {
51 | let n = document.createTextNode(txt);
52 | parent && parent.append(n);
53 | return n;
54 | }
55 |
56 | export function fragment() {
57 | return document.createDocumentFragment();
58 | }
59 |
--------------------------------------------------------------------------------
/app/js/icons.ts:
--------------------------------------------------------------------------------
1 | let ICONS:Record={};
2 | ICONS["playlist-music"] = ``;
5 | ICONS["folder"] = ``;
8 | ICONS["shuffle"] = ``;
11 | ICONS["artist"] = ``;
14 | ICONS["download"] = ``;
17 | ICONS["checkbox-marked-outline"] = ``;
20 | ICONS["magnify"] = ``;
23 | ICONS["delete"] = ``;
26 | ICONS["rewind"] = ``;
29 | ICONS["cancel"] = ``;
32 | ICONS["settings"] = ``;
35 | ICONS["pause"] = ``;
38 | ICONS["arrow-down-bold"] = ``;
41 | ICONS["filter-variant"] = ``;
44 | ICONS["volume-off"] = ``;
47 | ICONS["close"] = ``;
50 | ICONS["music"] = ``;
53 | ICONS["repeat"] = ``;
56 | ICONS["arrow-up-bold"] = ``;
59 | ICONS["keyboard-backspace"] = ``;
62 | ICONS["play"] = ``;
65 | ICONS["plus"] = ``;
68 | ICONS["content-save"] = ``;
71 | ICONS["library-music"] = ``;
74 | ICONS["fast-forward"] = ``;
77 | ICONS["volume-high"] = ``;
80 | ICONS["chevron-double-right"] = ``;
83 | ICONS["album"] = ``;
86 | ICONS["minus_unused"] = ``;
89 | export default ICONS;
90 |
--------------------------------------------------------------------------------
/app/js/item.ts:
--------------------------------------------------------------------------------
1 | import * as html from "./html.js";
2 |
3 |
4 | export default class Item extends HTMLElement {
5 | addButton(icon: string, cb: Function) {
6 | html.button({icon}, "", this).addEventListener("click", e => {
7 | e.stopPropagation(); // do not select/activate/whatever
8 | cb();
9 | });
10 | }
11 |
12 | protected buildTitle(title: string) {
13 | return html.node("span", {className:"title"}, title, this);
14 | }
15 |
16 | matchPrefix(prefix: string) {
17 | return (this.textContent || "").match(/\w+/g)!.some(word => word.toLowerCase().startsWith(prefix));
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/app/js/mpd-mock.js:
--------------------------------------------------------------------------------
1 | export function command(cmd) {
2 | console.warn(`mpd-mock does not know "${cmd}"`);
3 | }
4 |
5 | export function status() {
6 | return {
7 | volume: 50,
8 | elapsed: 10,
9 | duration: 70,
10 | state: "play"
11 | }
12 | }
13 |
14 | export function currentSong() {
15 | return {
16 | duration: 70,
17 | file: "name.mp3",
18 | Title: "Title of song",
19 | Artist: "Artist of song",
20 | Album: "Album of song",
21 | Track: "6",
22 | Id: 2
23 | }
24 | }
25 |
26 | export function listQueue() {
27 | return [
28 | {Id:1, Track:"5", Title:"Title 1", Artist:"AAA", Album:"BBB", duration:30, file:"a.mp3"},
29 | currentSong(),
30 | {Id:3, Track:"7", Title:"Title 3", Artist:"CCC", Album:"DDD", duration:230, file:"c.mp3"},
31 | ];
32 | }
33 |
34 | export function listPlaylists() {
35 | return [
36 | "Playlist 1",
37 | "Playlist 2",
38 | "Playlist 3"
39 | ];
40 | }
41 |
42 | export function listPath(path) {
43 | return {
44 | "directory": [
45 | {"directory": "Dir 1"},
46 | {"directory": "Dir 2"},
47 | {"directory": "Dir 3"}
48 | ],
49 | "file": [
50 | {"file": "File 1"},
51 | {"file": "File 2"},
52 | {"file": "File 3"}
53 | ]
54 | }
55 | }
56 |
57 | export function listTags(tag, filter = null) {
58 | switch (tag) {
59 | case "AlbumArtist": return ["Artist 1", "Artist 2", "Artist 3"];
60 | case "Album": return ["Album 1", "Album 2", "Album 3"];
61 | }
62 | }
63 |
64 | export function listSongs(filter, window = null) {
65 | return listQueue();
66 | }
67 |
68 | export function searchSongs(filter) {
69 | return listQueue();
70 | }
71 |
72 | export function albumArt(songUrl) {
73 | return new Promise(resolve => setTimeout(resolve, 1000));
74 | return null;
75 | }
76 |
77 | export function init() {}
78 |
--------------------------------------------------------------------------------
/app/js/mpd.ts:
--------------------------------------------------------------------------------
1 | import * as parser from "./parser.js";
2 | import { TagFilter } from "./elements/tag.js";
3 |
4 |
5 | interface Command {
6 | cmd: string;
7 | resolve: (lines: string[]) => void;
8 | reject: (line: string | CloseEvent) => void;
9 | }
10 |
11 | export default class MPD {
12 | protected queue: Command[] = [];
13 | protected current?: Command;
14 | protected canTerminateIdle = false;
15 |
16 | static async connect() {
17 | let response = await fetch("ticket", {method:"POST"});
18 | let ticket = (await response.json()).ticket;
19 |
20 | let ws = new WebSocket(createURL(ticket).href);
21 |
22 | return new Promise((resolve, reject) => {
23 | let mpd: MPD;
24 | let initialCommand = {resolve: () => resolve(mpd), reject, cmd:""};
25 | mpd = new this(ws, initialCommand);
26 | });
27 | }
28 |
29 | constructor(protected ws: WebSocket, initialCommand: Command) {
30 | this.current = initialCommand;
31 |
32 | ws.addEventListener("message", e => this._onMessage(e));
33 | ws.addEventListener("close", e => this._onClose(e));
34 | }
35 |
36 | onClose(e: CloseEvent) {}
37 | onChange(changed: string[]) {}
38 |
39 | command(cmds: string | string[]) {
40 | let cmd = (cmds instanceof Array ? ["command_list_begin", ...cmds, "command_list_end"].join("\n") : cmds);
41 |
42 | return new Promise((resolve, reject) => {
43 | this.queue.push({cmd, resolve, reject});
44 |
45 | if (!this.current) {
46 | this.advanceQueue();
47 | } else if (this.canTerminateIdle) {
48 | this.ws.send("noidle");
49 | this.canTerminateIdle = false;
50 | }
51 | });
52 | }
53 |
54 | async status() {
55 | let lines = await this.command("status");
56 | return parser.linesToStruct(lines) as parser.StatusData;
57 | }
58 |
59 | async currentSong() {
60 | let lines = await this.command("currentsong");
61 | return parser.linesToStruct(lines);
62 | }
63 |
64 | async listQueue() {
65 | let lines = await this.command("playlistinfo");
66 | return parser.songList(lines);
67 | }
68 |
69 | async listPlaylists() {
70 | interface Result {
71 | playlist: string | string[];
72 | }
73 | let lines = await this.command("listplaylists");
74 | let parsed = parser.linesToStruct(lines);
75 |
76 | let list = parsed.playlist;
77 | if (!list) { return []; }
78 | return (list instanceof Array ? list : [list]);
79 | }
80 |
81 | async listPlaylistItems(name: string) {
82 | let lines = await this.command(`listplaylistinfo "${escape(name)}"`);
83 | return parser.songList(lines);
84 | }
85 |
86 | async listPath(path: string) {
87 | let lines = await this.command(`lsinfo "${escape(path)}"`);
88 | return parser.pathContents(lines);
89 | }
90 |
91 | async listTags(tag: string, filter: TagFilter = {}) {
92 | let tokens = ["list", tag];
93 | if (Object.keys(filter).length) {
94 | tokens.push(serializeFilter(filter));
95 |
96 | let fakeGroup = Object.keys(filter)[0]; // FIXME hack for MPD < 0.21.6
97 | tokens.push("group", fakeGroup);
98 | }
99 | let lines = await this.command(tokens.join(" "));
100 | let parsed = parser.linesToStruct(lines);
101 | return ([] as string[]).concat(tag in parsed ? parsed[tag] : []);
102 | }
103 |
104 | async listSongs(filter: TagFilter, window?: number[]) {
105 | let tokens = ["find", serializeFilter(filter)];
106 | window && tokens.push("window", window.join(":"));
107 | let lines = await this.command(tokens.join(" "));
108 | return parser.songList(lines);
109 | }
110 |
111 | async searchSongs(filter: TagFilter) {
112 | let tokens = ["search", serializeFilter(filter, "contains")];
113 | let lines = await this.command(tokens.join(" "));
114 | return parser.songList(lines);
115 | }
116 |
117 | async albumArt(songUrl: string) {
118 | let data: number[] = [];
119 | let offset = 0;
120 | let params = ["albumart", `"${escape(songUrl)}"`, offset];
121 |
122 | interface Metadata {
123 | size: string;
124 | binary: string;
125 | }
126 |
127 | while (1) {
128 | params[2] = offset;
129 | try {
130 | let lines = await this.command(params.join(" "));
131 | data = data.concat(lines[2] as unknown as number[]); // !
132 | let metadata = parser.linesToStruct(lines.slice(0, 2));
133 | if (data.length >= Number(metadata.size)) { return data; }
134 | offset += Number(metadata.binary);
135 | } catch (e) { return null; }
136 | }
137 | return null;
138 | }
139 |
140 | _onMessage(e: MessageEvent) {
141 | if (!this.current) { return; }
142 |
143 | let lines = JSON.parse(e.data);
144 | let last = lines.pop();
145 | if (last.startsWith("OK")) {
146 | this.current.resolve(lines);
147 | } else {
148 | console.warn(last);
149 | this.current.reject(last);
150 | }
151 | this.current = undefined;
152 |
153 | if (this.queue.length > 0) {
154 | this.advanceQueue();
155 | } else {
156 | setTimeout(() => this.idle(), 0); // only after resolution callbacks
157 | }
158 | }
159 |
160 | _onClose(e: CloseEvent) {
161 | console.warn(e);
162 | this.current && this.current.reject(e);
163 | this.onClose(e);
164 | }
165 |
166 | protected advanceQueue() {
167 | this.current = this.queue.shift()!;
168 | this.ws.send(this.current.cmd);
169 | }
170 |
171 | protected async idle() {
172 | if (this.current) { return; }
173 |
174 | interface Result {
175 | changed?: string | string[]
176 | }
177 |
178 | this.canTerminateIdle = true;
179 | let lines = await this.command("idle stored_playlist playlist player options mixer");
180 | this.canTerminateIdle = false;
181 | let changed = parser.linesToStruct(lines).changed || [];
182 | changed = ([] as string[]).concat(changed);
183 | (changed.length > 0) && this.onChange(changed);
184 | }
185 | }
186 |
187 | export function escape(str: string) {
188 | return str.replace(/(['"\\])/g, "\\$1");
189 | }
190 |
191 | export function serializeFilter(filter: TagFilter, operator = "==") {
192 | let tokens = ["("];
193 | Object.entries(filter).forEach(([key, value], index) => {
194 | index && tokens.push(" AND ");
195 | tokens.push(`(${key} ${operator} "${escape(value)}")`);
196 | });
197 | tokens.push(")");
198 |
199 | let filterStr = tokens.join("");
200 | return `"${escape(filterStr)}"`;
201 | }
202 |
203 | function createURL(ticket: string) {
204 | let url = new URL(location.href);
205 | url.protocol = ( url.protocol == 'https:' ? "wss" : "ws" );
206 | url.hash = "";
207 | url.searchParams.set("ticket", ticket);
208 | return url;
209 | }
210 |
--------------------------------------------------------------------------------
/app/js/parser.ts:
--------------------------------------------------------------------------------
1 | export type Struct = Record;
2 | export interface SongData {
3 | Id: string;
4 | file: string;
5 | Track?: string;
6 | Title?: string;
7 | Artist?: string;
8 | AlbumArtist?: string;
9 | Album?: string;
10 | duration?: string;
11 | }
12 |
13 | export interface PathData {
14 | file?: string;
15 | directory?: string;
16 | playlist?: string;
17 | }
18 |
19 | export interface StatusData {
20 | random?: string;
21 | repeat?: string;
22 | volume?: string;
23 | state?: string;
24 | elapsed?: string;
25 | }
26 |
27 | export function linesToStruct(lines: string[]): T {
28 | let result: Struct = {};
29 |
30 | lines.forEach(line => {
31 | let cindex = line.indexOf(":");
32 | if (cindex == -1) { throw new Error(`Malformed line "${line}"`); }
33 | let key = line.substring(0, cindex);
34 | let value = line.substring(cindex+2);
35 | if (key in result) {
36 | let old = result[key];
37 | if (old instanceof Array) {
38 | old.push(value);
39 | } else {
40 | result[key] = [old!, value];
41 | }
42 | } else {
43 | result[key] = value;
44 | }
45 | });
46 | return result as T;
47 | }
48 |
49 | export function songList(lines: string[]) {
50 | let songs: SongData[] = [];
51 | let batch: string[] = [];
52 | while (lines.length) {
53 | let line = lines[0];
54 | if (line.startsWith("file:") && batch.length) {
55 | let song = linesToStruct(batch);
56 | songs.push(song);
57 | batch = [];
58 | }
59 | batch.push(lines.shift()!);
60 | }
61 |
62 | if (batch.length) {
63 | let song = linesToStruct(batch);
64 | songs.push(song);
65 | }
66 |
67 | return songs;
68 | }
69 |
70 | export function pathContents(lines: string[]) {
71 | const prefixes = ["file", "directory", "playlist"];
72 |
73 | let batch: string[] = [];
74 | let result: Record = {};
75 | let batchPrefix = "";
76 | prefixes.forEach(prefix => result[prefix] = []);
77 |
78 | while (lines.length) {
79 | let line = lines[0];
80 | let prefix = line.split(":")[0];
81 | if (prefixes.includes(prefix)) { // begin of a new batch
82 | if (batch.length) { result[batchPrefix].push(linesToStruct(batch)); }
83 | batchPrefix = prefix;
84 | batch = [];
85 | }
86 | batch.push(lines.shift()!);
87 | }
88 |
89 | if (batch.length) { result[batchPrefix].push(linesToStruct(batch)); }
90 |
91 | return result;
92 | }
93 |
--------------------------------------------------------------------------------
/app/js/selection.ts:
--------------------------------------------------------------------------------
1 | import * as html from "./html.js";
2 |
3 |
4 | type Mode = "single" | "multi";
5 |
6 | interface Command {
7 | cb: Function;
8 | label: string;
9 | icon: string;
10 | className?: string;
11 | };
12 |
13 | export default class Selection {
14 | readonly commands = new Commands();
15 | protected items: HTMLElement[] = [];
16 | protected mode!: Mode;
17 |
18 | constructor() {
19 | this.hide();
20 | }
21 |
22 | configure(items: HTMLElement[], mode: Mode, commands: Command[]) {
23 | this.mode = mode;
24 |
25 | let allCommands: Command[] = [];
26 |
27 | if (mode == "multi") {
28 | allCommands.push({
29 | cb: () => items.forEach(item => this.add(item)),
30 | label:"Select all",
31 | icon:"checkbox-marked-outline"
32 | });
33 | }
34 |
35 | allCommands.push(...commands);
36 |
37 | allCommands.push({
38 | cb: () => this.clear(),
39 | icon: "cancel",
40 | label: "Cancel",
41 | className: "last"
42 | });
43 |
44 | let buttons = allCommands.map(command => {
45 | let button = buildButton(command);
46 | button.addEventListener("click", _ => {
47 | const arg = (mode == "single" ? this.items[0] : this.items);
48 | command.cb(arg);
49 | });
50 | return button;
51 | });
52 |
53 | this.commands.innerHTML = "";
54 | this.commands.append(...buttons);
55 |
56 | items.forEach(item => {
57 | item.onclick = () => this.toggle(item);
58 | });
59 |
60 | this.clear();
61 | }
62 |
63 | clear() {
64 | while (this.items.length) { this.remove(this.items[0]); }
65 | }
66 |
67 | protected toggle(node: HTMLElement) {
68 | if (this.items.includes(node)) {
69 | this.remove(node);
70 | } else {
71 | this.add(node);
72 | }
73 | }
74 |
75 | protected add(node: HTMLElement) {
76 | if (this.items.includes(node)) { return; }
77 | const length = this.items.length;
78 | this.items.push(node);
79 | node.classList.add("selected");
80 |
81 | if (this.mode == "single" && length > 0) { this.remove(this.items[0]); }
82 |
83 | if (length == 0) { this.show(); }
84 | }
85 |
86 | protected remove(node: HTMLElement) {
87 | const index = this.items.indexOf(node);
88 | this.items.splice(index, 1);
89 | node.classList.remove("selected");
90 | if (this.items.length == 0) { this.hide(); }
91 | }
92 |
93 | protected show() { this.commands.hidden = false; }
94 | protected hide() { this.commands.hidden = true; }
95 | }
96 |
97 | function buildButton(command: Command) {
98 | const button = html.button({icon:command.icon});
99 | if (command.className) { button.className = command.className; }
100 | html.node("span", {}, command.label, button);
101 | return button;
102 | }
103 |
104 | class Commands extends HTMLElement {}
105 | customElements.define("cyp-commands", Commands);
106 |
--------------------------------------------------------------------------------
/app/svg2js.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | ARGS="ed -O -N svg=http://www.w3.org/2000/svg"
4 | DELETE="-d //comment() -d //svg:defs -d //svg:title -d //svg:desc -d //@fill -d //@stroke -d //@stroke-width -d //@fill-rule -d //@width -d //@height"
5 |
6 | process_svg () {
7 | NAME=$1
8 | ID=$(basename $NAME | sed -e 's/.svg//')
9 |
10 | DATA=$(cat $NAME | xmlstarlet fo -D -N | xmlstarlet $ARGS $DELETE | sed -e 's+xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" ++')
11 | printf "ICONS[\"$ID\"] = \`$DATA\`;\n"
12 | }
13 |
14 | if [ "$#" -ne 1 ]; then
15 | echo "Usage: $0 svg_directory"
16 | exit 1
17 | fi
18 |
19 | IMAGES=$(find "$1" -name "*.svg")
20 |
21 | printf "let ICONS={};\n"
22 | for i in $IMAGES; do
23 | process_svg $i
24 | done
25 | printf "export default ICONS;\n"
26 |
--------------------------------------------------------------------------------
/app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | // "allowJs": true,
4 | // "checkJs": true,
5 | "noEmit": true,
6 | // "lib": ["es2017", "dom"],
7 | "target": "es2017",
8 | "baseUrl": "js",
9 | "strict": true,
10 | "noImplicitReturns": true,
11 | "strictFunctionTypes": true
12 | },
13 | "files": ["js/cyp.ts"]
14 | }
15 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const static = require("node-static");
2 | const app = new static.Server("./app");
3 | const port = Number(process.argv[2]) || process.env.PORT || 8080;
4 |
5 | let tickets = [];
6 |
7 | const cmd = process.env.YOUTUBE || "youtube-dl";
8 |
9 | function escape(arg) {
10 | return `'${arg.replace(/'/g, `'\\''`)}'`;
11 | }
12 |
13 | function isUrl(str) {
14 | return str.startsWith("https:");
15 | }
16 |
17 | function searchYoutube(q, limit, response) {
18 | response.setHeader("Content-Type", "text/plain"); // necessary for firefox to read by chunks
19 |
20 | if (isUrl(q)) {
21 | console.log("YouTube searching url", q, limit);
22 | q = escape(q);
23 | } else {
24 | console.log("YouTube searching query", q, limit);
25 | q = escape(`ytsearch${limit}:${q}`);
26 | }
27 |
28 | const command = `set -o pipefail; ${cmd} -j ${q} | jq "{id,title}" | jq -s .`;
29 |
30 | require("child_process").exec(command, {shell:"bash"}, (error, stdout, stderr) => {
31 | if (error) {
32 | console.log("error", error);
33 | response.writeHead(500);
34 | response.end(error.message);
35 | } else {
36 | response.end(stdout);
37 | }
38 | });
39 | }
40 |
41 |
42 | function downloadYoutube(id, response) {
43 | response.setHeader("Content-Type", "text/plain"); // necessary for firefox to read by chunks
44 |
45 | console.log("YouTube downloading", id);
46 | let args = [
47 | "-f", "bestaudio",
48 | "-o", `${__dirname}/_youtube/%(title)s-%(id)s.%(ext)s`,
49 | "--",
50 | id
51 | ]
52 | let child = require("child_process").spawn(cmd, args);
53 |
54 | child.stdout.setEncoding("utf8").on("data", chunk => response.write(chunk));
55 | child.stderr.setEncoding("utf8").on("data", chunk => response.write(chunk));
56 |
57 | child.on("error", error => {
58 | console.log(error);
59 | response.writeHead(500);
60 | response.end(error.message);
61 | });
62 |
63 | child.on("close", code => {
64 | if (code != 0) { // fixme
65 | }
66 | response.end();
67 | });
68 | }
69 |
70 | function handleYoutubeSearch(url, response) {
71 | let q = url.searchParams.get("q");
72 | let limit = url.searchParams.get("limit") || "";
73 | if (q) {
74 | searchYoutube(q, limit, response);
75 | } else {
76 | response.writeHead(404);
77 | response.end();
78 | }
79 | }
80 |
81 | function handleYoutubeDownload(request, response) {
82 | let str = "";
83 | request.setEncoding("utf8");
84 | request.on("data", chunk => str += chunk);
85 | request.on("end", () => {
86 | let id = require("querystring").parse(str)["id"];
87 | if (id) {
88 | downloadYoutube(id, response);
89 | } else {
90 | response.writeHead(404);
91 | response.end();
92 | }
93 | });
94 | }
95 |
96 | function handleTicket(request, response) {
97 | request.resume().on("end", () => {
98 | let ticket = require("crypto").randomBytes(16).toString("hex");
99 | tickets.push(ticket);
100 | if (tickets.length > 10) { tickets.shift(); }
101 |
102 | let data = {ticket};
103 | response.setHeader("Content-Type", "application/json");
104 | response.end(JSON.stringify(data));
105 | });
106 | }
107 |
108 | function onRequest(request, response) {
109 | const url = new URL(request.url, "http://localhost");
110 |
111 | switch (true) {
112 | case request.method == "GET" && url.pathname == "/youtube":
113 | return handleYoutubeSearch(url, response);
114 |
115 | case request.method == "POST" && url.pathname == "/youtube":
116 | return handleYoutubeDownload(request, response);
117 |
118 | case request.method == "POST" && url.pathname == "/ticket":
119 | return handleTicket(request, response);
120 |
121 | default:
122 | return request.on("end", () => app.serve(request, response)).resume();
123 | }
124 | }
125 |
126 | function requestValidator(request) {
127 | let ticket = request.resourceURL.query["ticket"];
128 | let index = tickets.indexOf(ticket);
129 | if (index > -1) {
130 | tickets.splice(index, 1);
131 | return true;
132 | } else {
133 | return false;
134 | }
135 | }
136 |
137 | let httpServer = require("http").createServer(onRequest).listen(port);
138 | let passwords = {};
139 | try {
140 | passwords = require("./passwords.json");
141 | console.log("loaded passwords.json file");
142 | } catch (e) {
143 | console.log("no passwords.json found");
144 | }
145 | require("ws2mpd").ws2mpd(httpServer, requestValidator, passwords);
146 | require("ws2mpd").logging(false);
147 |
--------------------------------------------------------------------------------
/misc/cyp.service.template:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Control Your Player
3 |
4 | [Service]
5 | WorkingDirectory=$PWD
6 | ExecStart=/usr/bin/env node .
7 |
8 | [Install]
9 | WantedBy=default.target
10 |
--------------------------------------------------------------------------------
/misc/screen1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ondras/cyp/abb70c825477585bd3ae5a82158d1c139f5aefbf/misc/screen1.png
--------------------------------------------------------------------------------
/misc/screen2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ondras/cyp/abb70c825477585bd3ae5a82158d1c139f5aefbf/misc/screen2.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cyp",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "dependencies": {
7 | "custom-range": "^1.1.0",
8 | "node-static": "^0.7.11",
9 | "ws2mpd": "^2.4.0"
10 | },
11 | "devDependencies": {
12 | "esbuild": "^0.17.19",
13 | "less": "^3.9.0"
14 | },
15 | "scripts": {
16 | "test": "echo \"Error: no test specified\" && exit 1"
17 | },
18 | "author": "",
19 | "license": "ISC",
20 | "engines": {
21 | "node": ">=10.0.0"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------