├── .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 | ![](misc/screen1.png) ![](misc/screen2.png) 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"] = ` 426 | 427 | `; 428 | ICONS["folder"] = ` 429 | 430 | `; 431 | ICONS["shuffle"] = ` 432 | 433 | `; 434 | ICONS["artist"] = ` 435 | 436 | `; 437 | ICONS["download"] = ` 438 | 439 | `; 440 | ICONS["checkbox-marked-outline"] = ` 441 | 442 | `; 443 | ICONS["magnify"] = ` 444 | 445 | `; 446 | ICONS["delete"] = ` 447 | 448 | `; 449 | ICONS["rewind"] = ` 450 | 451 | `; 452 | ICONS["cancel"] = ` 453 | 454 | `; 455 | ICONS["settings"] = ` 456 | 457 | `; 458 | ICONS["pause"] = ` 459 | 460 | `; 461 | ICONS["arrow-down-bold"] = ` 462 | 463 | `; 464 | ICONS["filter-variant"] = ` 465 | 466 | `; 467 | ICONS["volume-off"] = ` 468 | 469 | `; 470 | ICONS["close"] = ` 471 | 472 | `; 473 | ICONS["music"] = ` 474 | 475 | `; 476 | ICONS["repeat"] = ` 477 | 478 | `; 479 | ICONS["arrow-up-bold"] = ` 480 | 481 | `; 482 | ICONS["keyboard-backspace"] = ` 483 | 484 | `; 485 | ICONS["play"] = ` 486 | 487 | `; 488 | ICONS["plus"] = ` 489 | 490 | `; 491 | ICONS["content-save"] = ` 492 | 493 | `; 494 | ICONS["library-music"] = ` 495 | 496 | `; 497 | ICONS["fast-forward"] = ` 498 | 499 | `; 500 | ICONS["volume-high"] = ` 501 | 502 | `; 503 | ICONS["chevron-double-right"] = ` 504 | 505 | `; 506 | ICONS["album"] = ` 507 | 508 | `; 509 | ICONS["minus_unused"] = ` 510 | 511 | `; 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"] = ` 3 | 4 | `; 5 | ICONS["folder"] = ` 6 | 7 | `; 8 | ICONS["shuffle"] = ` 9 | 10 | `; 11 | ICONS["artist"] = ` 12 | 13 | `; 14 | ICONS["download"] = ` 15 | 16 | `; 17 | ICONS["checkbox-marked-outline"] = ` 18 | 19 | `; 20 | ICONS["magnify"] = ` 21 | 22 | `; 23 | ICONS["delete"] = ` 24 | 25 | `; 26 | ICONS["rewind"] = ` 27 | 28 | `; 29 | ICONS["cancel"] = ` 30 | 31 | `; 32 | ICONS["settings"] = ` 33 | 34 | `; 35 | ICONS["pause"] = ` 36 | 37 | `; 38 | ICONS["arrow-down-bold"] = ` 39 | 40 | `; 41 | ICONS["filter-variant"] = ` 42 | 43 | `; 44 | ICONS["volume-off"] = ` 45 | 46 | `; 47 | ICONS["close"] = ` 48 | 49 | `; 50 | ICONS["music"] = ` 51 | 52 | `; 53 | ICONS["repeat"] = ` 54 | 55 | `; 56 | ICONS["arrow-up-bold"] = ` 57 | 58 | `; 59 | ICONS["keyboard-backspace"] = ` 60 | 61 | `; 62 | ICONS["play"] = ` 63 | 64 | `; 65 | ICONS["plus"] = ` 66 | 67 | `; 68 | ICONS["content-save"] = ` 69 | 70 | `; 71 | ICONS["library-music"] = ` 72 | 73 | `; 74 | ICONS["fast-forward"] = ` 75 | 76 | `; 77 | ICONS["volume-high"] = ` 78 | 79 | `; 80 | ICONS["chevron-double-right"] = ` 81 | 82 | `; 83 | ICONS["album"] = ` 84 | 85 | `; 86 | ICONS["minus_unused"] = ` 87 | 88 | `; 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 | --------------------------------------------------------------------------------