Spaces:
Running
Running
Upload 45 files
Browse files- .cadence-backend.log +7 -0
- .gitignore +7 -0
- DEPLOY-FREE.md +181 -0
- DEPLOY.md +162 -0
- Dockerfile +20 -0
- Procfile +1 -0
- README.md +224 -8
- app.py +939 -0
- fly.toml +35 -0
- railway.json +15 -0
- render.yaml +24 -0
- requirements.txt +5 -0
- run.sh +87 -0
- standalone/build_standalone.py +219 -0
- standalone/icons/apple-touch-icon.png +0 -0
- standalone/icons/favicon-64.png +0 -0
- standalone/icons/icon-1024.png +0 -0
- standalone/icons/icon-128.png +0 -0
- standalone/icons/icon-180.png +0 -0
- standalone/icons/icon-192.png +0 -0
- standalone/icons/icon-512.png +0 -0
- standalone/icons/icon-96.png +0 -0
- standalone/icons/icon-maskable-512.png +0 -0
- standalone/make_icons.py +84 -0
- standalone/pwa/manifest.webmanifest +30 -0
- standalone/pwa/sw.js +101 -0
- standalone/runtime.js +494 -0
- static/app.js +649 -0
- static/dash.css +570 -0
- static/dash.js +1092 -0
- static/pwa/icons/apple-touch-icon.png +0 -0
- static/pwa/icons/favicon-64.png +0 -0
- static/pwa/icons/icon-1024.png +0 -0
- static/pwa/icons/icon-128.png +0 -0
- static/pwa/icons/icon-180.png +0 -0
- static/pwa/icons/icon-192.png +0 -0
- static/pwa/icons/icon-512.png +0 -0
- static/pwa/icons/icon-96.png +0 -0
- static/pwa/icons/icon-maskable-512.png +0 -0
- static/pwa/manifest.webmanifest +90 -0
- static/pwa/sw.js +101 -0
- static/style.css +260 -0
- templates/dashboard.html +148 -0
- templates/index.html +108 -0
- tunnel.sh +307 -0
.cadence-backend.log
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Traceback (most recent call last):
|
| 2 |
+
File "C:\Users\char\Downloads\cadence-dashboard (1)\cadence\app.py", line 938, in <module>
|
| 3 |
+
print(f"\U0001f3a7 Cadence running on http://0.0.0.0:{PORT}")
|
| 4 |
+
File "C:\Users\char\AppData\Local\anaconda3\Lib\encodings\cp1252.py", line 19, in encode
|
| 5 |
+
return codecs.charmap_encode(input,self.errors,encoding_table)[0]
|
| 6 |
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
| 7 |
+
UnicodeEncodeError: 'charmap' codec can't encode character '\U0001f3a7' in position 0: character maps to <undefined>
|
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.venv/
|
| 4 |
+
venv/
|
| 5 |
+
.env
|
| 6 |
+
data/
|
| 7 |
+
*.log
|
DEPLOY-FREE.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploying Cadence with no credit card
|
| 2 |
+
|
| 3 |
+
Everything Cadence *is* is open source. Only the hosting was proprietary, and you don't need to
|
| 4 |
+
pay for it.
|
| 5 |
+
|
| 6 |
+
| Part | Licence |
|
| 7 |
+
|---|---|
|
| 8 |
+
| Flask | BSD-3-Clause |
|
| 9 |
+
| gunicorn | MIT |
|
| 10 |
+
| yt-dlp | Unlicense (public domain) |
|
| 11 |
+
| ytmusicapi | MIT |
|
| 12 |
+
| cloudflared (tunnel) | Apache-2.0 |
|
| 13 |
+
| Cadence itself (HTML/CSS/JS/Python) | yours — no dependencies beyond the above, no framework, no build step, no CDN |
|
| 14 |
+
|
| 15 |
+
The three options below all cost **0 € and need no card**. Option A is the one I'd pick.
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## Option A — your own machine + a free https tunnel ⭐
|
| 20 |
+
|
| 21 |
+
Best choice, for a practical reason: the audio is fetched from **your** internet connection, so
|
| 22 |
+
YouTube doesn't see a datacenter IP and playback actually works. Cloudflare's tunnel gives you
|
| 23 |
+
a public https URL with no account, no card, no domain.
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
# terminal 1 — the backend
|
| 27 |
+
cd cadence
|
| 28 |
+
./run.sh # → http://localhost:8000
|
| 29 |
+
|
| 30 |
+
# terminal 2 — the https door
|
| 31 |
+
bash tunnel.sh # installs nothing, prints a URL like:
|
| 32 |
+
# https://quite-pets-invalid-renewal.trycloudflare.com
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
Open that URL on your phone → **Install Cadence**. Because the backend now serves the app, the
|
| 36 |
+
manifest, the service worker *and* the API from that one origin, the installed app works with
|
| 37 |
+
live search and playback immediately — no backend URL to configure, no CORS, no mixed content.
|
| 38 |
+
|
| 39 |
+
If `cloudflared` is missing, `bash tunnel.sh` **downloads it automatically** into `./bin`
|
| 40 |
+
(Apache-2.0, ~35 MB, no sudo, no account). The URL appears within ~20 s and the script then
|
| 41 |
+
verifies it from the public internet. If the tunnel connects but never gets a URL, QUIC is
|
| 42 |
+
usually blocked on your network — the script retries with `--protocol http2` on its own.
|
| 43 |
+
|
| 44 |
+
No `cloudflared` at all? `bash tunnel.sh --ssh` uses localhost.run instead (no download).
|
| 45 |
+
|
| 46 |
+
### One command instead of two
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
bash tunnel.sh --with-backend
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
That starts the backend for you (installing dependencies on first run), waits until it answers,
|
| 53 |
+
opens the tunnel, verifies the public URL and stops the backend again when you press Ctrl+C.
|
| 54 |
+
Use it when you just want the phone URL without juggling two terminals.
|
| 55 |
+
|
| 56 |
+
### `backend not answering on http://127.0.0.1:8000`
|
| 57 |
+
|
| 58 |
+
`tunnel.sh` needs the backend running in a *separate* terminal. If it says nothing is listening:
|
| 59 |
+
|
| 60 |
+
| symptom | cause | fix |
|
| 61 |
+
|---|---|---|
|
| 62 |
+
| `Permission denied` | exec bit lost by zip/checkout | `bash run.sh` |
|
| 63 |
+
| exits immediately, no message | old `run.sh` + missing `python3-venv` | re-download, or `sudo apt install python3-venv` |
|
| 64 |
+
| `ModuleNotFoundError: yt_dlp` | dependencies never installed | `bash run.sh` (installs them) |
|
| 65 |
+
| `Address already in use` | another program on 8000 | `PORT=8010 bash run.sh` then `PORT=8010 bash tunnel.sh` |
|
| 66 |
+
| answered once, now dead | terminal closed / laptop slept | start `bash run.sh` again |
|
| 67 |
+
|
| 68 |
+
`run.sh` now names the exact cause instead of failing silently, and `bash run.sh` with Cadence
|
| 69 |
+
already running just tells you it's up. Check the port yourself any time:
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
curl -s http://127.0.0.1:8000/healthz # → {"ok":true,...}
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
### `Permission denied` on `./tunnel.sh`
|
| 76 |
+
|
| 77 |
+
Zip/checkout tools often drop the executable bit. Any of these works:
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
bash tunnel.sh # never needs the exec bit
|
| 81 |
+
chmod +x tunnel.sh # or make it executable once
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
**Things to know**
|
| 85 |
+
|
| 86 |
+
- The URL is **temporary**: it exists while `tunnel.sh` runs and changes each restart. Fine for
|
| 87 |
+
personal use — you just reopen the URL (or reinstall) when it changes.
|
| 88 |
+
- It's **public**: anyone with that URL can use your instance. There is no login. Stop the
|
| 89 |
+
tunnel when you're done. (A permanent URL needs a free Cloudflare account and a domain —
|
| 90 |
+
still no card, but no longer zero-setup.)
|
| 91 |
+
- Your computer must be awake for the app to work. That's the trade for free + unblocked audio.
|
| 92 |
+
|
| 93 |
+
Prefer not to use Cloudflare at all? Any tunnel works, e.g. `ssh -R 80:localhost:8000
|
| 94 |
+
serveo.net` or `localhost.run` — no account either. The app doesn't care.
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
## Option B — fully static hosting (free, no backend)
|
| 99 |
+
|
| 100 |
+
Host only the app. Search and playback are unavailable, but the dashboard, the demo library,
|
| 101 |
+
charts, genre browsing and offline support all work — and it installs like an app.
|
| 102 |
+
|
| 103 |
+
Take `cadence-android/` and upload it to any of these (none ask for a card):
|
| 104 |
+
|
| 105 |
+
| Host | Free? | Card? | Notes |
|
| 106 |
+
|---|---|---|---|
|
| 107 |
+
| **Codeberg Pages** | yes | no | non-profit, open-source-only host — closest to your ask |
|
| 108 |
+
| **GitHub Pages** | yes | no | push the folder, Settings → Pages → main/root |
|
| 109 |
+
| **Cloudflare Pages** | yes | no | drag-and-drop a folder |
|
| 110 |
+
| **Netlify Drop** | yes | no | <https://app.netlify.com/drop>, no account needed |
|
| 111 |
+
| **Any web server you own** | yes | no | nginx/Caddy/Apache with https |
|
| 112 |
+
|
| 113 |
+
Codeberg example, entirely with open-source tooling:
|
| 114 |
+
|
| 115 |
+
```bash
|
| 116 |
+
cd cadence-android
|
| 117 |
+
git init && git add . && git commit -m "Cadence"
|
| 118 |
+
git remote add origin https://codeberg.org/<you>/cadence.git
|
| 119 |
+
git push -u origin main
|
| 120 |
+
# then in the repo: Settings → Pages → branch main → https://<you>.codeberg.page/cadence/
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
The app starts in demo mode and can later be pointed at an Option A/C URL via the **● demo**
|
| 124 |
+
pill.
|
| 125 |
+
|
| 126 |
+
---
|
| 127 |
+
|
| 128 |
+
## Option C — a free public backend, no card
|
| 129 |
+
|
| 130 |
+
If you want a backend that runs without your computer, **Hugging Face Spaces** is the only
|
| 131 |
+
widely-used host I know of that runs a Docker service on a free tier with **no card**:
|
| 132 |
+
|
| 133 |
+
1. Create a Space at <https://huggingface.co/new-space> → SDK: **Docker** → Blank.
|
| 134 |
+
2. Upload the contents of `cadence/` (or `git clone` the Space and copy the files in).
|
| 135 |
+
3. Add a `README.md` at the Space root with this front-matter:
|
| 136 |
+
|
| 137 |
+
```yaml
|
| 138 |
+
---
|
| 139 |
+
title: Cadence
|
| 140 |
+
sdk: docker
|
| 141 |
+
app_port: 7860
|
| 142 |
+
pinned: false
|
| 143 |
+
---
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
4. In the Space's *Settings → Variables*, set `PORT=7860` and `CADENCE_CACHE=/tmp/cadence`.
|
| 147 |
+
5. The Space builds and serves at `https://<user>-<space>.hf.space` — installable PWA included.
|
| 148 |
+
|
| 149 |
+
Trade-offs, honestly:
|
| 150 |
+
|
| 151 |
+
- Spaces are **public** — anyone can visit yours. No auth exists in Cadence.
|
| 152 |
+
- The free tier **sleeps** after inactivity and has limited RAM.
|
| 153 |
+
- It runs on datacenter IPs, so **YouTube will probably refuse the audio** (the
|
| 154 |
+
`youtube_blocked` message). Search and browsing work; playback likely won't. That's exactly
|
| 155 |
+
the problem Option A avoids.
|
| 156 |
+
- `CADENCE_CACHE` must be a writable path (`/tmp`), and it is not persisted between restarts.
|
| 157 |
+
|
| 158 |
+
Other free-ish backend hosts (Koyeb, Fly, Railway, Render) now ask for a card for verification
|
| 159 |
+
— which is what sent you here. If you ever do add a card, `DEPLOY.md` has those configs ready.
|
| 160 |
+
|
| 161 |
+
---
|
| 162 |
+
|
| 163 |
+
## Which should you pick?
|
| 164 |
+
|
| 165 |
+
| You want… | Use |
|
| 166 |
+
|---|---|
|
| 167 |
+
| App on your Android phone, real playback, zero cost | **A** — `bash run.sh` + `bash tunnel.sh`, install from the https URL |
|
| 168 |
+
| A link you can share that never needs your PC on | **B** — static demo, installable, free forever |
|
| 169 |
+
| A public backend you can browse from anywhere | **C** — with the datacenter-audio caveat |
|
| 170 |
+
|
| 171 |
+
## Sanity check after any deployment
|
| 172 |
+
|
| 173 |
+
```bash
|
| 174 |
+
BASE=https://your-url.example
|
| 175 |
+
curl -s $BASE/healthz # {"status":"ok",...}
|
| 176 |
+
curl -s $BASE/api/stats # JSON
|
| 177 |
+
curl -s -o /dev/null -w '%{http_code}\n' $BASE/manifest.webmanifest # 200
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
Then open `$BASE` on the phone: the **Install Cadence** bar should appear. If it doesn't, the
|
| 181 |
+
page isn't https, or the service worker didn't register (check the browser console).
|
DEPLOY.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploying Cadence
|
| 2 |
+
|
| 3 |
+
> **No credit card?** See **[DEPLOY-FREE.md](DEPLOY-FREE.md)** — your own machine plus a free
|
| 4 |
+
> https tunnel, or static hosting. Everything below uses platforms that may ask for card
|
| 5 |
+
> verification.
|
| 6 |
+
|
| 7 |
+
The app is a single Flask service: it serves the dashboard, the installable PWA assets
|
| 8 |
+
(`/manifest.webmanifest`, `/sw.js`, icons) **and** the JSON/audio API from one origin. That
|
| 9 |
+
matters — a same-origin deployment means no CORS setup and no mixed-content problems, and the
|
| 10 |
+
installed Android app talks to the same URL it was installed from.
|
| 11 |
+
|
| 12 |
+
Everything below is prepared and tested; only the account-level clicks are yours.
|
| 13 |
+
|
| 14 |
+
| File | Used by |
|
| 15 |
+
|---|---|
|
| 16 |
+
| `Dockerfile` | Render, Railway, Fly, any container host |
|
| 17 |
+
| `render.yaml` | Render Blueprint (includes a 5 GB persistent disk) |
|
| 18 |
+
| `railway.json` | Railway (Dockerfile builder + health check) |
|
| 19 |
+
| `fly.toml` | Fly.io (volume mount, https redirect, scale-to-zero) |
|
| 20 |
+
| `Procfile` | Heroku-style hosts |
|
| 21 |
+
| `/healthz` | health probe for all of them (`{"status":"ok"}`) |
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## 0. Put the code in a git repo
|
| 26 |
+
|
| 27 |
+
All three platforms deploy from git.
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
cd cadence
|
| 31 |
+
git init
|
| 32 |
+
git add .
|
| 33 |
+
git commit -m "Cadence: dashboard, API, PWA"
|
| 34 |
+
# create an empty repo on GitHub first, then:
|
| 35 |
+
git remote add origin https://github.com/<you>/cadence.git
|
| 36 |
+
git push -u origin main
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
`standalone/`, `run.sh`, the platform files and `*.zip` are excluded from the image by
|
| 40 |
+
`.dockerignore`; `__pycache__` and `.venv` are in `.gitignore`.
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
## 1. Render (recommended, and the simplest)
|
| 45 |
+
|
| 46 |
+
1. Sign in at <https://render.com>, click **New → Blueprint**.
|
| 47 |
+
2. Pick your repo. Render reads `render.yaml` and shows a `cadence` web service with a
|
| 48 |
+
`/data` disk.
|
| 49 |
+
3. **Apply** — first build takes ~3–5 minutes, mostly `pip install`.
|
| 50 |
+
|
| 51 |
+
You get `https://cadence-xxxx.onrender.com`.
|
| 52 |
+
|
| 53 |
+
Notes:
|
| 54 |
+
- `plan: starter` (paid) keeps the service warm. The **free** plan sleeps after 15 minutes of
|
| 55 |
+
inactivity, so the first request afterwards takes ~30 s — the health check absorbs it.
|
| 56 |
+
- Change `region:` in `render.yaml` if you prefer a different one; `frankfurt` is closest to
|
| 57 |
+
Switzerland.
|
| 58 |
+
- The disk keeps the audio cache across restarts. 5 GB is plenty (the cache self-evicts at
|
| 59 |
+
3 GB).
|
| 60 |
+
|
| 61 |
+
Without the blueprint, creating a plain **Web Service** also works: Render detects the
|
| 62 |
+
Dockerfile automatically. Add env vars `CADENCE_CACHE=/data` and `PORT=10000`, and a disk
|
| 63 |
+
mounted at `/data`.
|
| 64 |
+
|
| 65 |
+
---
|
| 66 |
+
|
| 67 |
+
## 2. Railway
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
npm i -g @railway/cli
|
| 71 |
+
railway login
|
| 72 |
+
railway init # create a new project
|
| 73 |
+
railway up # builds the Dockerfile and deploys
|
| 74 |
+
|
| 75 |
+
# persistent cache (otherwise it's rebuilt after every deploy)
|
| 76 |
+
railway volume add --mount-path /data
|
| 77 |
+
railway variables set CADENCE_CACHE=/data
|
| 78 |
+
railway domain # gives you an https URL
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
`railway.json` already sets the start command, `/healthz` health check and single-replica
|
| 82 |
+
policy. Keep **one replica**: the download locks and telemetry counters live in-process.
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## 3. Fly.io
|
| 87 |
+
|
| 88 |
+
```bash
|
| 89 |
+
curl -L https://fly.io/install.sh | sh
|
| 90 |
+
fly auth login
|
| 91 |
+
|
| 92 |
+
cd cadence
|
| 93 |
+
fly launch --copy-config --name cadence-<something-unique> --region zrh
|
| 94 |
+
fly volumes create cadence_cache --size 5 --region zrh
|
| 95 |
+
fly deploy
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
`fly.toml` sets `force_https = true`, mounts the volume at `/data`, and stops the machine when
|
| 99 |
+
idle (`auto_stop_machines = "stop"`) — a cold start costs a couple of seconds.
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## After deploying — check these three things
|
| 104 |
+
|
| 105 |
+
```bash
|
| 106 |
+
BASE=https://your-app.example
|
| 107 |
+
|
| 108 |
+
curl -s $BASE/healthz # {"status":"ok",...}
|
| 109 |
+
curl -s -o /dev/null -w '%{http_code}\n' $BASE/manifest.webmanifest # 200
|
| 110 |
+
curl -s $BASE/api/stats # JSON, cached_tracks etc.
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
Then open `$BASE` on your phone → **Install Cadence** appears → install. The installed app is
|
| 114 |
+
same-origin with the API, so search and playback work with no extra configuration (no ⚙
|
| 115 |
+
backend URL needed).
|
| 116 |
+
|
| 117 |
+
### Expectations on cloud hosting
|
| 118 |
+
|
| 119 |
+
**Audio downloads may be blocked.** YouTube rate-limits media fetches from datacenter IPs.
|
| 120 |
+
Render, Railway and Fly all run in datacenters, so playback can fail with
|
| 121 |
+
|
| 122 |
+
```json
|
| 123 |
+
{"error":"YouTube refused to serve this audio to the server's IP address.","code":"youtube_blocked"}
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
The dashboard says so explicitly and pauses instead of skipping. Search, charts, genres,
|
| 127 |
+
artist pages and suggestions all keep working — only the audio bytes are refused. Options:
|
| 128 |
+
|
| 129 |
+
- Route `yt-dlp` through a residential proxy: set `HTTPS_PROXY` in the service's environment.
|
| 130 |
+
- Keep playback local (`./run.sh` on your machine) and use the deployed instance for browsing.
|
| 131 |
+
- Some regions/IPs are not blocked — it's worth simply testing your deployment.
|
| 132 |
+
|
| 133 |
+
**Memory.** `yt-dlp` plus Flask sits around 150–250 MB; the Fly config requests 512 MB. The
|
| 134 |
+
audio is streamed from disk, not buffered in RAM.
|
| 135 |
+
|
| 136 |
+
**Cold starts.** Free tiers sleep. The service worker means the *interface* opens instantly
|
| 137 |
+
even then; only live data waits for the wake-up.
|
| 138 |
+
|
| 139 |
+
**Single worker.** `--workers 1 --threads 16` is deliberate. Multiple worker processes would
|
| 140 |
+
each keep their own cache index, download locks and stats, so the engine panel would flicker
|
| 141 |
+
and two workers could download the same track. Scale with threads, not workers.
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
## Updating a deployment
|
| 146 |
+
|
| 147 |
+
```bash
|
| 148 |
+
git add -A && git commit -m "..." && git push # Render/Railway auto-deploy
|
| 149 |
+
fly deploy # Fly
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
The service worker caches the shell, so after an update users get the new version on their next
|
| 153 |
+
navigation (network-first for pages). To force it, bump `VERSION` in `static/pwa/sw.js` and
|
| 154 |
+
`standalone/pwa/sw.js`.
|
| 155 |
+
|
| 156 |
+
## Rebuilding the PWA from the dashboard sources
|
| 157 |
+
|
| 158 |
+
```bash
|
| 159 |
+
python3 standalone/build_standalone.py # → cadence-dashboard.html + cadence-android/
|
| 160 |
+
python3 standalone/make_icons.py # if you change the icon design
|
| 161 |
+
cp standalone/pwa/sw.js static/pwa/sw.js # keep the served worker in sync
|
| 162 |
+
```
|
Dockerfile
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Cadence — dashboard + streaming backend
|
| 2 |
+
# docker build -t cadence . && docker run -p 8000:8000 -v cadence-data:/data cadence
|
| 3 |
+
FROM python:3.12-slim
|
| 4 |
+
|
| 5 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 6 |
+
PIP_NO_CACHE_DIR=1 \
|
| 7 |
+
PORT=8000 \
|
| 8 |
+
CADENCE_CACHE=/data
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install -r requirements.txt
|
| 13 |
+
COPY app.py ./
|
| 14 |
+
COPY templates ./templates
|
| 15 |
+
COPY static ./static
|
| 16 |
+
|
| 17 |
+
VOLUME ["/data"]
|
| 18 |
+
EXPOSE 8000
|
| 19 |
+
# single worker on purpose: the audio cache, download locks and telemetry are in-process
|
| 20 |
+
CMD ["sh", "-c", "gunicorn --bind 0.0.0.0:${PORT:-8000} --workers 1 --threads 16 --timeout 180 app:app"]
|
Procfile
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
web: gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 16 --timeout 180 app:app
|
README.md
CHANGED
|
@@ -1,10 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
title: Cadence
|
| 3 |
-
emoji: 📊
|
| 4 |
-
colorFrom: green
|
| 5 |
-
colorTo: indigo
|
| 6 |
-
sdk: static
|
| 7 |
-
pinned: false
|
| 8 |
-
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🎧 Cadence — YouTube music dashboard
|
| 2 |
+
|
| 3 |
+
**Three ways to run it:**
|
| 4 |
+
|
| 5 |
+
| | What | Needs |
|
| 6 |
+
|---|---|---|
|
| 7 |
+
| **`cadence-dashboard.html`** | The whole dashboard in **one file** — double-click it, host it anywhere, no install | nothing (demo mode) |
|
| 8 |
+
| **This project** | The same dashboard wired to a real backend: YouTube Music search, radio, audio streaming | Python 3 (`./run.sh`) |
|
| 9 |
+
| **`cadence-android/`** | The same dashboard as an **installable Android app** (PWA): home-screen icon, full-screen, offline | any https static host |
|
| 10 |
+
|
| 11 |
+
The single file starts in **demo mode** (built-in catalog, simulated playback, every panel and
|
| 12 |
+
interaction live) and can be pointed at the backend from **⚙ Settings** for real search and audio.
|
| 13 |
+
To rebuild it after editing the dashboard: `python3 standalone/build_standalone.py`.
|
| 14 |
+
|
| 15 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
+
# Backend version
|
| 18 |
+
|
| 19 |
+
Search any song by name, stream it, and let the engine keep the music going with artist
|
| 20 |
+
radios and similar-song suggestions — wrapped in a browser dashboard with live playback,
|
| 21 |
+
suggestion and cache telemetry.
|
| 22 |
+
|
| 23 |
+
No API key needed: search/radio/artist/genre data comes from YouTube Music's public data
|
| 24 |
+
(via `ytmusicapi`), and the audio is pulled with `yt-dlp` and served by this server so the
|
| 25 |
+
browser can play, seek and buffer it like a normal audio file.
|
| 26 |
+
|
| 27 |
+
```
|
| 28 |
+
┌────────────────────────────────────────────────────────────────────────────┐
|
| 29 |
+
│ Cadence · dashboard [ search songs, artists, albums… ] CH Queue │
|
| 30 |
+
├───────────┬───────────────────────────────────────────────┬────────────────┤
|
| 31 |
+
│ Overview │ ▢ now playing ▢ queue ▢ buffered ▢ radio │ Up next │
|
| 32 |
+
│ Search │ ┌──────────────────┐ ┌─────────────────────┐ │ (queue rail) │
|
| 33 |
+
│ Trending │ │ Now playing │ │ Suggestion feed │ │ track 3 of 42 │
|
| 34 |
+
│ Genres │ │ art + controls │ │ + reason labels │ │ ... │
|
| 35 |
+
│ Recent │ └──────────────────┘ └─────────────────────┘ │ │
|
| 36 |
+
│ ─────── │ ┌──────────────────┐ ┌─────────────────────┐ │ │
|
| 37 |
+
│ Engine │ │ Trending in CH │ │ Trending artists │ │ │
|
| 38 |
+
│ cache … │ └──────────────────┘ └─────────────────────┘ │ │
|
| 39 |
+
├───────────┴───────────────────────────────────────────────┴────────────────┤
|
| 40 |
+
│ now playing · transport · seek · volume │
|
| 41 |
+
└────────────────────────────────────────────────────────────────────────────┘
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
## Run it
|
| 45 |
+
|
| 46 |
+
```bash
|
| 47 |
+
./run.sh # installs deps if needed → http://localhost:8000
|
| 48 |
+
# or
|
| 49 |
+
pip install -r requirements.txt
|
| 50 |
+
PORT=8000 python3 app.py
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
Then open **http://localhost:8000** (dashboard) — the older single-column player is kept at
|
| 54 |
+
**/classic**.
|
| 55 |
+
|
| 56 |
+
## Dashboard views
|
| 57 |
+
|
| 58 |
+
| View | What you get |
|
| 59 |
+
|---|---|
|
| 60 |
+
| **Overview** | Live tiles (now playing, queue depth, *buffered ahead*, radio engine), a now-playing hero with one-click *Play similar* / *Artist radio*, the **suggestion feed** with a reason per track ("Up next", "Similar to X", "More from X"), trending tracks + artists, and recently played |
|
| 61 |
+
| **Search** | Any song / artist / album by name, with live suggestions in the search box; artists render as cards, songs as a playable list |
|
| 62 |
+
| **Trending** | YouTube Music charts for 69 regions (defaults to 🇨🇭 Switzerland) — charting tracks plus trending artists with rank and up/down trend |
|
| 63 |
+
| **Genres & moods** | 38 tiles (moods + genres) → curated playlists → playable track lists, all browsable without typing anything |
|
| 64 |
+
| **Artist** | Top songs, monthly listeners, "Fans also like" similar artists, albums & singles |
|
| 65 |
+
| **Album / playlist** | Full track lists with play-all and per-track *similar songs* |
|
| 66 |
+
| **Recently played** | Local listening history (stored in your browser) |
|
| 67 |
+
| **Queue rail** | The full upcoming queue, click any row to jump; collapses via the Queue button |
|
| 68 |
+
| **Engine panel** | Server telemetry: cache size vs. 3 GB cap, tracks cached, downloads, average fetch time, in-flight downloads, uptime |
|
| 69 |
+
|
| 70 |
+
Everything is hash-routed (`#/artist/…`, `#/playlist/…`, `#/genre/…`), so views are
|
| 71 |
+
shareable and the browser back button works.
|
| 72 |
+
|
| 73 |
+
## How the "keep playing" engine works
|
| 74 |
+
|
| 75 |
+
* Play any track → the server downloads the audio once into a local cache and serves it
|
| 76 |
+
with HTTP Range support (seeking/buffering/replays are instant).
|
| 77 |
+
* While you listen, the next track is **prefetched** — the "Buffered ahead" tile polls
|
| 78 |
+
`/api/ready/<id>` and flips to *Ready ✓* when it's on disk.
|
| 79 |
+
* When the queue runs low, radio suggestions for the current track are appended
|
| 80 |
+
automatically (the "Radio" badge appears in the player bar), so playback never dead-ends.
|
| 81 |
+
* ✨ on any row = "play similar songs" → seeds a station from that track.
|
| 82 |
+
|
| 83 |
+
## Keyboard
|
| 84 |
+
|
| 85 |
+
`/` search · `space` play/pause · `n` / `p` next/prev · `←` `→` skip 10 s · `s` shuffle ·
|
| 86 |
+
`r` repeat (off/queue/track) · `m` mute · `q` toggle queue rail. OS media keys work too.
|
| 87 |
+
|
| 88 |
+
## API
|
| 89 |
+
|
| 90 |
+
| Endpoint | Purpose |
|
| 91 |
+
|---|---|
|
| 92 |
+
| `GET /api/search?q=&limit=` | songs + artists for a query |
|
| 93 |
+
| `GET /api/suggest?q=` | search-box autocomplete |
|
| 94 |
+
| `GET /api/radio?videoId=&limit=` | "similar songs" / up-next radio for a track |
|
| 95 |
+
| `GET /api/artist?id=\|name=` | top songs, albums, similar artists, monthly listeners |
|
| 96 |
+
| `GET /api/artist/radio?id=` | continuous artist station |
|
| 97 |
+
| `GET /api/trending?country=CH` | regional chart tracks + trending artists + region list |
|
| 98 |
+
| `GET /api/genres` · `GET /api/genre?params=` · `GET /api/playlist?id=` | browse tree and playlists |
|
| 99 |
+
| `GET /api/album?id=` | album track list |
|
| 100 |
+
| `GET /api/stream/<videoId>` | cached audio with Range support (the `<audio>` source) |
|
| 101 |
+
| `POST /api/prefetch/<videoId>` · `GET /api/ready/<videoId>` | warm the cache / check if a track is buffered |
|
| 102 |
+
| `GET /api/stats` | engine telemetry for the sidebar panel |
|
| 103 |
+
| `GET /healthz` | health probe (`{"status":"ok"}`) for Render/Fly/Railway |
|
| 104 |
+
| `GET /manifest.webmanifest` · `GET /sw.js` · `/static/pwa/icons/*` | installable-PWA assets |
|
| 105 |
+
| `GET /api/img?u=` · `GET /api/thumb/<videoId>` | image proxies (allow-listed hosts only) |
|
| 106 |
+
|
| 107 |
+
## Deploy it
|
| 108 |
+
|
| 109 |
+
**No credit card: [DEPLOY-FREE.md](DEPLOY-FREE.md)** — run the backend on your own machine and
|
| 110 |
+
put a free open-source https tunnel in front of it (`bash run.sh` + `bash tunnel.sh`, or a single `bash tunnel.sh --with-backend`), or host the app
|
| 111 |
+
as static files. Everything Cadence depends on is open source (Flask, gunicorn, yt-dlp,
|
| 112 |
+
ytmusicapi, cloudflared); only the hosting was ever proprietary.
|
| 113 |
+
|
| 114 |
+
**Paid/simple clouds: [DEPLOY.md](DEPLOY.md)** — Render, Railway and Fly are pre-configured
|
| 115 |
+
(`render.yaml`, `railway.json`, `fly.toml`). Whichever you pick, the dashboard, the PWA assets
|
| 116 |
+
and the API are served from the same https origin, so the installed Android app needs no
|
| 117 |
+
backend URL and there is no CORS or mixed-content setup.
|
| 118 |
+
|
| 119 |
+
The dashboard is server-backed, so it needs the Flask app running (a static host alone can't
|
| 120 |
+
stream or search).
|
| 121 |
+
|
| 122 |
+
**Docker**
|
| 123 |
+
|
| 124 |
+
```bash
|
| 125 |
+
docker build -t cadence .
|
| 126 |
+
docker run -p 8000:8000 -v cadence-data:/data cadence
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
**Render / Railway / Fly.io / any PaaS** — push the repo, it detects the `Procfile`:
|
| 130 |
+
`gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 16 --timeout 180 app:app`.
|
| 131 |
+
Set `CADENCE_CACHE` to a mounted volume (e.g. `/data`) so the audio cache survives restarts.
|
| 132 |
+
One worker is deliberate: the cache, download locks and stats live in-process.
|
| 133 |
+
|
| 134 |
+
**Reverse proxy** — keep response buffering off for `/api/stream/` (Nginx: `proxy_buffering off;`)
|
| 135 |
+
so seeking stays snappy.
|
| 136 |
+
|
| 137 |
+
## The standalone single-file dashboard
|
| 138 |
+
|
| 139 |
+
`cadence-dashboard.html` is generated from these same sources by `standalone/build_standalone.py`:
|
| 140 |
+
|
| 141 |
+
* CSS, icons, dashboard logic and a demo engine are inlined — **zero external requests**, so it
|
| 142 |
+
works from `file://`, a USB stick, GitHub Pages/Netlify/S3, or an e-mail attachment.
|
| 143 |
+
* **Demo mode** synthesises a catalog (12 artists, 48 tracks, 32 regions, moods, genres, playlists),
|
| 144 |
+
generates artwork as inline SVG, and runs a virtual player so progress, queue growth, radio
|
| 145 |
+
suggestions and the telemetry panel all behave exactly like the live app.
|
| 146 |
+
* **Server mode** (⚙ Settings → enter a URL) switches every request to the real backend. The
|
| 147 |
+
backend sends CORS headers, so the file can be hosted on a different origin. Mixed content
|
| 148 |
+
applies: an `https` page needs an `https` backend.
|
| 149 |
+
|
| 150 |
+
## Android app (installable PWA)
|
| 151 |
+
|
| 152 |
+
`standalone/build_standalone.py` also emits `cadence-android/` — the app shell plus
|
| 153 |
+
`manifest.webmanifest`, `sw.js` and a full icon set. Host that folder on any https static host
|
| 154 |
+
(Netlify Drop, GitHub Pages, nginx…), open it on the phone and tap **Install**.
|
| 155 |
+
|
| 156 |
+
On phones the dashboard switches to a purpose-built layout (≤640 px): 2×2 tile grid,
|
| 157 |
+
full-width search, icon nav strip, compact 104 px player bar with thumb-reachable transport,
|
| 158 |
+
and the queue as a full-height sheet. Touch devices also drop the keyboard hints and turn
|
| 159 |
+
artist names inside track rows into plain text — Chrome's touch adjustment would otherwise
|
| 160 |
+
turn a "play this song" tap into "open this artist".
|
| 161 |
+
|
| 162 |
+
Install criteria, mixed-content rules and how to get a signed `.apk` are documented in
|
| 163 |
+
[`cadence-android/README.md`](cadence-android/README.md).
|
| 164 |
+
|
| 165 |
+
## Layout
|
| 166 |
+
|
| 167 |
+
```
|
| 168 |
+
cadence/
|
| 169 |
+
├── app.py # Flask: search, radio, artist, trending, genres, stream, images, stats
|
| 170 |
+
├── templates/dashboard.html # the dashboard shell (nav · panels · rail · player bar)
|
| 171 |
+
├── templates/index.html # the classic single-column UI (/classic)
|
| 172 |
+
├── static/dash.css # dashboard theme (dark, glassy)
|
| 173 |
+
├── static/dash.js # router, views, player, radio growth, telemetry
|
| 174 |
+
├── static/style.css static/app.js # classic UI
|
| 175 |
+
├── standalone/runtime.js # demo engine + backend connector (injected into the single file)
|
| 176 |
+
├── standalone/build_standalone.py # builds cadence-dashboard.html
|
| 177 |
+
├── requirements.txt · run.sh · Dockerfile · Procfile
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
## How search ranks results
|
| 181 |
+
|
| 182 |
+
`/api/search` runs three YouTube Music searches in parallel (relevance-ordered, filtered songs,
|
| 183 |
+
filtered artists) and merges them with de-duplication. Ordering:
|
| 184 |
+
|
| 185 |
+
1. **YouTube's own relevance position** leads — it encodes popularity, so the canonical recording
|
| 186 |
+
beats karaoke and cover versions ("karma police" → Radiohead, not a band called Karma Police).
|
| 187 |
+
2. **Exact title / artist matches** lift near-ties — `"song 2"` puts Blur's track first.
|
| 188 |
+
3. **Artist queries** are detected automatically (many results by one artist vs. many songs with
|
| 189 |
+
that title) and that artist's catalogue is promoted — `"nirvana"`, `"blur"`, `"drake"`.
|
| 190 |
+
4. Results sharing nothing with the query are **dropped**, so a nonsense query returns an honest
|
| 191 |
+
"no matches" with suggestions instead of random noise.
|
| 192 |
+
|
| 193 |
+
Typical latency is 0.5–1.5 s (the three lookups run concurrently, not in sequence).
|
| 194 |
+
|
| 195 |
+
## Troubleshooting
|
| 196 |
+
|
| 197 |
+
**"YouTube refused to serve this audio to the server's IP address"** (HTTP 503, code `youtube_blocked`)
|
| 198 |
+
— YouTube rate-limits/blocks media downloads from many **datacenter and cloud IPs** (VPS, CI, some
|
| 199 |
+
PaaS egress ranges). The dashboard keeps working (search, charts, genres, suggestions, artist pages);
|
| 200 |
+
only the audio bytes are refused.
|
| 201 |
+
* Run it on your **own machine / home connection** — this is the normal setup and works.
|
| 202 |
+
* On a VPS, route `yt-dlp` through a **residential VPN/proxy** (`HTTPS_PROXY` env var), or use a
|
| 203 |
+
local machine as the playback backend.
|
| 204 |
+
* The app tells you this explicitly instead of silently skipping tracks, and stops rather than
|
| 205 |
+
hammering YouTube with retries.
|
| 206 |
+
|
| 207 |
+
**`WARNING: No supported JavaScript runtime could be found`** — harmless for audio-only use. Current
|
| 208 |
+
`yt-dlp` prefers the `deno` runtime for YouTube's JS challenges; most formats still resolve without
|
| 209 |
+
it. If a specific track returns *"Requested format is not available"*, install `deno` and re-run.
|
| 210 |
+
|
| 211 |
+
**First play is slow, then instant** — by design: the track is downloaded once into the cache
|
| 212 |
+
(≈1–2 s), then served locally with Range support.
|
| 213 |
+
|
| 214 |
+
**Port already in use** — `PORT=8010 ./run.sh`.
|
| 215 |
+
|
| 216 |
+
## Notes & limits
|
| 217 |
+
|
| 218 |
+
* Audio cache: `~/.cache/cadence` locally, `/data` in Docker (max ~3 GB, oldest evicted).
|
| 219 |
+
Images cached in `…/img`.
|
| 220 |
+
* Only the first play of a track costs a download (~1–2 s); after that it's local.
|
| 221 |
+
* Regional trending charts are YouTube *trending videos*, so some titles arrive in
|
| 222 |
+
video-style formatting rather than clean song metadata. Genre/mood playlists come from
|
| 223 |
+
YouTube Music proper.
|
| 224 |
+
* Lyrics are not included — YouTube Music's lyrics endpoint rejects key-less requests.
|
| 225 |
+
* Built for personal use: it talks to the same public endpoints a browser does, so be
|
| 226 |
+
sensible with volume.
|
app.py
ADDED
|
@@ -0,0 +1,939 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Cadence — a YouTube-powered music player.
|
| 4 |
+
|
| 5 |
+
* Search any song / artist / album (YouTube Music data, no API key needed).
|
| 6 |
+
* Streams the audio through this server (yt-dlp), so the browser can play,
|
| 7 |
+
seek and buffer it like a normal <audio> file.
|
| 8 |
+
* Suggestions: "up next" radio for any track, artist radio, similar artists
|
| 9 |
+
and top songs for any artist.
|
| 10 |
+
|
| 11 |
+
Run: python3 app.py -> http://0.0.0.0:8000
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import hashlib
|
| 16 |
+
import mimetypes
|
| 17 |
+
import os
|
| 18 |
+
import re
|
| 19 |
+
import threading
|
| 20 |
+
import time
|
| 21 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import requests
|
| 25 |
+
import yt_dlp
|
| 26 |
+
from flask import Flask, abort, jsonify, redirect, render_template, request, send_file
|
| 27 |
+
from ytmusicapi import YTMusic
|
| 28 |
+
|
| 29 |
+
# --------------------------------------------------------------------------- #
|
| 30 |
+
# Config
|
| 31 |
+
# --------------------------------------------------------------------------- #
|
| 32 |
+
APP_DIR = Path(__file__).resolve().parent
|
| 33 |
+
CACHE_ROOT = Path(os.environ.get("CADENCE_CACHE", Path.home() / ".cache" / "cadence"))
|
| 34 |
+
AUDIO_DIR = CACHE_ROOT / "audio"
|
| 35 |
+
IMG_DIR = CACHE_ROOT / "img"
|
| 36 |
+
for d in (AUDIO_DIR, IMG_DIR):
|
| 37 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 38 |
+
|
| 39 |
+
# evict oldest audio above this size (configurable for small hosting volumes)
|
| 40 |
+
CACHE_MAX_BYTES = int(os.environ.get("CADENCE_MAX_CACHE_BYTES", 3 * 1024**3))
|
| 41 |
+
PORT = int(os.environ.get("PORT", "8000"))
|
| 42 |
+
|
| 43 |
+
VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$")
|
| 44 |
+
SAFE_BROWSE_RE = re.compile(r"^[A-Za-z0-9_-]{5,60}$")
|
| 45 |
+
|
| 46 |
+
MIME = {
|
| 47 |
+
".m4a": "audio/mp4",
|
| 48 |
+
".mp4": "audio/mp4",
|
| 49 |
+
".aac": "audio/aac",
|
| 50 |
+
".webm": "audio/webm",
|
| 51 |
+
".opus": "audio/ogg",
|
| 52 |
+
".ogg": "audio/ogg",
|
| 53 |
+
".mp3": "audio/mpeg",
|
| 54 |
+
".wav": "audio/wav",
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
app = Flask(__name__, static_folder="static", template_folder="templates")
|
| 58 |
+
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0
|
| 59 |
+
app.json.sort_keys = False
|
| 60 |
+
|
| 61 |
+
# --------------------------------------------------------------------------- #
|
| 62 |
+
# ytmusicapi access — a small pool of worker threads so each thread keeps its
|
| 63 |
+
# own (thread-affine) YTMusic client alive.
|
| 64 |
+
# --------------------------------------------------------------------------- #
|
| 65 |
+
_tls = threading.local()
|
| 66 |
+
_yt_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="ytmusic")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _yt_call_retry(job, attempts=2):
|
| 70 |
+
"""YouTube Music occasionally answers with a transient error; one quiet retry fixes most."""
|
| 71 |
+
last = None
|
| 72 |
+
for i in range(attempts):
|
| 73 |
+
try:
|
| 74 |
+
return yt_call(job)
|
| 75 |
+
except Exception as exc: # noqa: BLE001
|
| 76 |
+
last = exc
|
| 77 |
+
if i + 1 < attempts:
|
| 78 |
+
time.sleep(0.4)
|
| 79 |
+
raise last
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def yt_call(fn, timeout: float = 45):
|
| 83 |
+
"""Run fn(ytmusic_client) on a pooled worker thread that owns a client."""
|
| 84 |
+
|
| 85 |
+
def job():
|
| 86 |
+
client = getattr(_tls, "client", None)
|
| 87 |
+
if client is None:
|
| 88 |
+
client = YTMusic()
|
| 89 |
+
_tls.client = client
|
| 90 |
+
return fn(client)
|
| 91 |
+
|
| 92 |
+
return _yt_executor.submit(job).result(timeout=timeout)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# --------------------------------------------------------------------------- #
|
| 96 |
+
# Normalisation helpers
|
| 97 |
+
# --------------------------------------------------------------------------- #
|
| 98 |
+
def parse_duration(value) -> int:
|
| 99 |
+
if isinstance(value, (int, float)):
|
| 100 |
+
return int(value)
|
| 101 |
+
if isinstance(value, str):
|
| 102 |
+
parts = [p for p in value.strip().split(":") if p.strip()]
|
| 103 |
+
try:
|
| 104 |
+
total = 0
|
| 105 |
+
for p in parts:
|
| 106 |
+
total = total * 60 + int(p)
|
| 107 |
+
return total
|
| 108 |
+
except ValueError:
|
| 109 |
+
return 0
|
| 110 |
+
return 0
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def norm_artists(raw) -> list[dict]:
|
| 114 |
+
out: list[dict] = []
|
| 115 |
+
for a in raw.get("artists") or []:
|
| 116 |
+
if isinstance(a, dict) and a.get("name"):
|
| 117 |
+
out.append({"name": a["name"], "id": a.get("id")})
|
| 118 |
+
elif isinstance(a, str) and a.strip():
|
| 119 |
+
out.append({"name": a.strip(), "id": None})
|
| 120 |
+
if not out:
|
| 121 |
+
for key in ("author", "artist"):
|
| 122 |
+
v = raw.get(key)
|
| 123 |
+
if isinstance(v, str) and v.strip():
|
| 124 |
+
out.append({"name": v.strip(), "id": raw.get("channelId")})
|
| 125 |
+
break
|
| 126 |
+
return out
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def norm_track(raw: dict) -> dict | None:
|
| 130 |
+
vid = raw.get("videoId")
|
| 131 |
+
if not vid or not VIDEO_ID_RE.match(vid):
|
| 132 |
+
return None
|
| 133 |
+
artists = norm_artists(raw)
|
| 134 |
+
album = raw.get("album")
|
| 135 |
+
if isinstance(album, dict):
|
| 136 |
+
album = {"name": album.get("name"), "id": album.get("id")}
|
| 137 |
+
else:
|
| 138 |
+
album = None
|
| 139 |
+
duration = raw.get("duration_seconds")
|
| 140 |
+
if duration is None:
|
| 141 |
+
duration = parse_duration(raw.get("duration") or raw.get("length"))
|
| 142 |
+
return {
|
| 143 |
+
"video_id": vid,
|
| 144 |
+
"title": raw.get("title") or "Unknown track",
|
| 145 |
+
"artists": artists,
|
| 146 |
+
"artist_text": ", ".join(a["name"] for a in artists) or "Unknown artist",
|
| 147 |
+
"album": album,
|
| 148 |
+
"duration": int(duration or 0),
|
| 149 |
+
"year": raw.get("year"),
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def norm_tracks(raw_list) -> list[dict]:
|
| 154 |
+
seen, out = set(), []
|
| 155 |
+
for raw in raw_list or []:
|
| 156 |
+
if not isinstance(raw, dict):
|
| 157 |
+
continue
|
| 158 |
+
t = norm_track(raw)
|
| 159 |
+
if t and t["video_id"] not in seen:
|
| 160 |
+
seen.add(t["video_id"])
|
| 161 |
+
out.append(t)
|
| 162 |
+
return out
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def pick_thumb(thumbs, size: int = 226) -> str | None:
|
| 166 |
+
"""Smallest thumbnail that is at least <size> px wide (else the largest)."""
|
| 167 |
+
if not thumbs:
|
| 168 |
+
return None
|
| 169 |
+
ordered = sorted(thumbs, key=lambda t: t.get("width") or 0)
|
| 170 |
+
for t in ordered:
|
| 171 |
+
if (t.get("width") or 0) >= size:
|
| 172 |
+
return t.get("url")
|
| 173 |
+
return ordered[-1].get("url")
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def first_artist_id(track: dict) -> str | None:
|
| 177 |
+
for a in track.get("artists", []):
|
| 178 |
+
if a.get("id"):
|
| 179 |
+
return a["id"]
|
| 180 |
+
return None
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# --------------------------------------------------------------------------- #
|
| 184 |
+
# Streaming: download audio to a local cache, then serve it with Range support.
|
| 185 |
+
# --------------------------------------------------------------------------- #
|
| 186 |
+
_dl_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="download")
|
| 187 |
+
|
| 188 |
+
ENGINE_STARTED = time.time()
|
| 189 |
+
_stats_lock = threading.Lock()
|
| 190 |
+
_stats = {"downloads": 0, "inflight": 0, "download_seconds": 0.0, "bytes_served": 0, "failures": 0}
|
| 191 |
+
_locks_guard = threading.Lock()
|
| 192 |
+
_locks: dict[str, threading.Lock] = {}
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _lock_for(video_id: str) -> threading.Lock:
|
| 196 |
+
with _locks_guard:
|
| 197 |
+
return _locks.setdefault(video_id, threading.Lock())
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def cached_audio_path(video_id: str) -> Path | None:
|
| 201 |
+
for p in AUDIO_DIR.glob(f"{video_id}.*"):
|
| 202 |
+
if p.suffix != ".part" and p.stat().st_size > 4096:
|
| 203 |
+
return p
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _evict_cache() -> None:
|
| 208 |
+
files = sorted(AUDIO_DIR.glob("*.*"), key=lambda p: p.stat().st_mtime)
|
| 209 |
+
total = sum(p.stat().st_size for p in files)
|
| 210 |
+
for p in files:
|
| 211 |
+
if total <= CACHE_MAX_BYTES:
|
| 212 |
+
break
|
| 213 |
+
try:
|
| 214 |
+
size = p.stat().st_size
|
| 215 |
+
p.unlink()
|
| 216 |
+
total -= size
|
| 217 |
+
except OSError:
|
| 218 |
+
pass
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
class DownloadError(RuntimeError):
|
| 222 |
+
pass
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
class StreamBlocked(DownloadError):
|
| 226 |
+
"""YouTube refused to serve the media bytes to this server's IP."""
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
_BLOCK_MARKERS = ("403", "forbidden", "sign in to confirm", "not a bot", "too many requests")
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def _looks_blocked(msg: str) -> bool:
|
| 233 |
+
low = msg.lower()
|
| 234 |
+
return any(m in low for m in _BLOCK_MARKERS)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def download_audio(video_id: str) -> Path:
|
| 238 |
+
"""Download (or return already cached) audio for a video id."""
|
| 239 |
+
existing = cached_audio_path(video_id)
|
| 240 |
+
if existing:
|
| 241 |
+
os.utime(existing, None) # mark as recently used
|
| 242 |
+
return existing
|
| 243 |
+
|
| 244 |
+
with _lock_for(video_id):
|
| 245 |
+
existing = cached_audio_path(video_id)
|
| 246 |
+
if existing:
|
| 247 |
+
return existing
|
| 248 |
+
|
| 249 |
+
opts = {
|
| 250 |
+
"format": "bestaudio[ext=m4a]/bestaudio/best",
|
| 251 |
+
"outtmpl": str(AUDIO_DIR / f"{video_id}.%(ext)s"),
|
| 252 |
+
"quiet": True,
|
| 253 |
+
"no_warnings": True,
|
| 254 |
+
"noplaylist": True,
|
| 255 |
+
"noprogress": True,
|
| 256 |
+
"retries": 3,
|
| 257 |
+
"socket_timeout": 30,
|
| 258 |
+
"overwrites": True,
|
| 259 |
+
"cachedir": str(CACHE_ROOT / "ytdlp"),
|
| 260 |
+
}
|
| 261 |
+
t0 = time.time()
|
| 262 |
+
with _stats_lock:
|
| 263 |
+
_stats["inflight"] += 1
|
| 264 |
+
try:
|
| 265 |
+
try:
|
| 266 |
+
with yt_dlp.YoutubeDL(opts) as ydl:
|
| 267 |
+
ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
|
| 268 |
+
except Exception as exc: # noqa: BLE001
|
| 269 |
+
with _stats_lock:
|
| 270 |
+
_stats["failures"] += 1
|
| 271 |
+
detail = str(exc).splitlines()[0][:200] if str(exc).strip() else exc.__class__.__name__
|
| 272 |
+
if _looks_blocked(detail):
|
| 273 |
+
raise StreamBlocked(detail) from exc
|
| 274 |
+
raise DownloadError(detail) from exc
|
| 275 |
+
finally:
|
| 276 |
+
with _stats_lock:
|
| 277 |
+
_stats["inflight"] -= 1
|
| 278 |
+
_stats["downloads"] += 1
|
| 279 |
+
_stats["download_seconds"] += time.time() - t0
|
| 280 |
+
|
| 281 |
+
path = cached_audio_path(video_id)
|
| 282 |
+
if not path:
|
| 283 |
+
raise DownloadError("audio file was not produced")
|
| 284 |
+
_evict_cache()
|
| 285 |
+
return path
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def prefetch_audio(video_id: str) -> None:
|
| 289 |
+
if cached_audio_path(video_id):
|
| 290 |
+
return
|
| 291 |
+
_dl_executor.submit(lambda: _safe_download(video_id))
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _safe_download(video_id: str) -> None:
|
| 295 |
+
try:
|
| 296 |
+
download_audio(video_id)
|
| 297 |
+
except Exception: # noqa: BLE001 — prefetch is best-effort
|
| 298 |
+
pass
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# --------------------------------------------------------------------------- #
|
| 302 |
+
# API
|
| 303 |
+
# --------------------------------------------------------------------------- #
|
| 304 |
+
@app.after_request
|
| 305 |
+
def _cors(resp):
|
| 306 |
+
"""Let a standalone dashboard.html hosted anywhere (or opened from file://) call this API."""
|
| 307 |
+
resp.headers.setdefault("Access-Control-Allow-Origin", "*")
|
| 308 |
+
resp.headers.setdefault("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
| 309 |
+
resp.headers.setdefault("Access-Control-Allow-Headers", "Range, Content-Type, Accept")
|
| 310 |
+
resp.headers.setdefault("Access-Control-Expose-Headers", "Content-Range, Accept-Ranges, Content-Length")
|
| 311 |
+
resp.headers.setdefault("Access-Control-Max-Age", "86400")
|
| 312 |
+
return resp
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
@app.route("/api/<path:_any>", methods=["OPTIONS"])
|
| 316 |
+
def _cors_preflight(_any):
|
| 317 |
+
return ("", 204)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
# --------------------------------------------------------------------------- #
|
| 321 |
+
# PWA: installable app, served from the same origin as the API
|
| 322 |
+
# --------------------------------------------------------------------------- #
|
| 323 |
+
PWA_DIR = Path(__file__).parent / "static" / "pwa"
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
@app.get("/manifest.webmanifest")
|
| 327 |
+
def pwa_manifest():
|
| 328 |
+
resp = send_file(PWA_DIR / "manifest.webmanifest", mimetype="application/manifest+json")
|
| 329 |
+
resp.headers["Cache-Control"] = "public, max-age=3600"
|
| 330 |
+
return resp
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
@app.get("/sw.js")
|
| 334 |
+
def pwa_service_worker():
|
| 335 |
+
resp = send_file(PWA_DIR / "sw.js", mimetype="text/javascript")
|
| 336 |
+
# must live at the root with this header so it can control the whole origin
|
| 337 |
+
resp.headers["Service-Worker-Allowed"] = "/"
|
| 338 |
+
resp.headers["Cache-Control"] = "no-cache"
|
| 339 |
+
return resp
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def _selfcheck():
|
| 343 |
+
"""Log what the deployment depends on, so a bad container is obvious in the logs."""
|
| 344 |
+
import shutil as _sh
|
| 345 |
+
problems = []
|
| 346 |
+
if not (CACHE_ROOT / "audio").exists():
|
| 347 |
+
try:
|
| 348 |
+
(CACHE_ROOT / "audio").mkdir(parents=True, exist_ok=True)
|
| 349 |
+
(CACHE_ROOT / "img").mkdir(parents=True, exist_ok=True)
|
| 350 |
+
except OSError as exc:
|
| 351 |
+
problems.append(f"cache dir not writable: {exc}")
|
| 352 |
+
if not _sh.which("ffmpeg"):
|
| 353 |
+
# not fatal: we request a single m4a stream, so yt-dlp never needs to remux
|
| 354 |
+
app.logger.info("ffmpeg not installed — fine for audio-only m4a streams")
|
| 355 |
+
for problem in problems:
|
| 356 |
+
app.logger.error("startup: %s", problem)
|
| 357 |
+
return problems
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
@app.get("/index.html")
|
| 361 |
+
def index_html():
|
| 362 |
+
"""Some PWA start_urls and bookmarks point here; serve the app rather than a 404."""
|
| 363 |
+
return redirect("/", code=302)
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
@app.get("/healthz")
|
| 367 |
+
def healthz():
|
| 368 |
+
"""Render/Fly/Railway probes: cheap, no upstream calls."""
|
| 369 |
+
return jsonify(status="ok", uptime_seconds=int(time.time() - ENGINE_STARTED))
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
@app.get("/")
|
| 373 |
+
def index():
|
| 374 |
+
"""Dashboard UI (the main app)."""
|
| 375 |
+
return render_template("dashboard.html")
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def _rank_tracks(tracks, query, limit, rank_hint=None):
|
| 379 |
+
"""Order results the way a person expects.
|
| 380 |
+
|
| 381 |
+
Three signals, strongest first: the *song* they typed, the *artist* they typed, and —
|
| 382 |
+
as a stand-in for popularity — how early YouTube itself ranked the result.
|
| 383 |
+
"""
|
| 384 |
+
rank_hint = rank_hint or {}
|
| 385 |
+
ql = " ".join(query.lower().split())
|
| 386 |
+
tokens = [t for t in ql.replace("-", " ").replace("–", " ").split() if len(t) > 1]
|
| 387 |
+
|
| 388 |
+
# Is this an artist query or a song query? The results themselves tell us:
|
| 389 |
+
# "daft punk" matches an artist name (and one joker's song title), "song 2" a title.
|
| 390 |
+
artist_q = any((t.get("artist_text") or "").strip().lower() == ql for t in tracks)
|
| 391 |
+
title_q = any((t.get("title") or "").strip().lower() == ql for t in tracks)
|
| 392 |
+
|
| 393 |
+
# Many results sharing the exact title ⇒ a song query; a crowd of results by the same
|
| 394 |
+
# artist ⇒ an artist query whose own catalogue should lead ("nirvana", "blur").
|
| 395 |
+
title_hits = sum(1 for t in tracks if (t.get("title") or "").strip().lower() == ql)
|
| 396 |
+
same_artist = {}
|
| 397 |
+
for t in tracks:
|
| 398 |
+
key = (t.get("artist_text") or "").strip().lower()
|
| 399 |
+
same_artist[key] = same_artist.get(key, 0) + 1
|
| 400 |
+
# "nirvana" → 31 results by Nirvana vs 5 songs merely *called* Nirvana → artist query.
|
| 401 |
+
# "karma police" → as many covers named after the song as the band → song query.
|
| 402 |
+
artist_dominant = same_artist.get(ql, 0) >= max(3, 2.5 * title_hits)
|
| 403 |
+
w_title, w_artist = (160, 120)
|
| 404 |
+
if artist_q and not title_q:
|
| 405 |
+
w_title, w_artist = 120, 200
|
| 406 |
+
elif artist_q and title_q:
|
| 407 |
+
w_title, w_artist = 160, 170
|
| 408 |
+
|
| 409 |
+
def score(t):
|
| 410 |
+
title = (t.get("title") or "").lower()
|
| 411 |
+
artist = (t.get("artist_text") or "").lower()
|
| 412 |
+
album = ((t.get("album") or {}).get("name") or "").lower()
|
| 413 |
+
s = 0
|
| 414 |
+
if ql:
|
| 415 |
+
if title == ql:
|
| 416 |
+
s += w_title
|
| 417 |
+
elif ql in title:
|
| 418 |
+
s += w_title * 0.5
|
| 419 |
+
if artist == ql:
|
| 420 |
+
s += w_artist
|
| 421 |
+
elif ql in artist:
|
| 422 |
+
s += w_artist * 0.58
|
| 423 |
+
for tok in tokens:
|
| 424 |
+
if tok in title:
|
| 425 |
+
s += 7
|
| 426 |
+
if tok in artist:
|
| 427 |
+
s += 9
|
| 428 |
+
if tok in album:
|
| 429 |
+
s += 2
|
| 430 |
+
if tokens and all(tok in title for tok in tokens):
|
| 431 |
+
s += 26
|
| 432 |
+
if tokens and all(tok in artist for tok in tokens):
|
| 433 |
+
s += 22
|
| 434 |
+
# YouTube's relevance order is popularity-aware, so it leads — but a perfect
|
| 435 |
+
# artist/title match can outrank a slightly-better-ranked near miss.
|
| 436 |
+
idx = rank_hint.get(t.get("video_id"))
|
| 437 |
+
s += max(0, 240 - 12 * idx) if idx is not None else 0
|
| 438 |
+
if artist_dominant and artist == ql:
|
| 439 |
+
s += 260 # the artist they asked for, not a song named after them
|
| 440 |
+
return s
|
| 441 |
+
|
| 442 |
+
def relevant(t):
|
| 443 |
+
"""Does the query appear anywhere in the result at all?"""
|
| 444 |
+
if not tokens:
|
| 445 |
+
return True
|
| 446 |
+
hay = ((t.get("title") or "") + " " + (t.get("artist_text") or "") + " " +
|
| 447 |
+
((t.get("album") or {}).get("name") or "")).lower()
|
| 448 |
+
return any(tok in hay for tok in tokens)
|
| 449 |
+
|
| 450 |
+
pool = [t for t in tracks if relevant(t)]
|
| 451 |
+
if not pool: # nothing matched: report nothing rather than noise
|
| 452 |
+
return []
|
| 453 |
+
|
| 454 |
+
scored = sorted(((score(t), i, t) for i, t in enumerate(pool)), key=lambda x: (-x[0], x[1]))
|
| 455 |
+
seen, out = set(), []
|
| 456 |
+
for _, _, t in scored:
|
| 457 |
+
key = ((t.get("title") or "").strip().lower(), (t.get("artist_text") or "").strip().lower())
|
| 458 |
+
if key in seen:
|
| 459 |
+
continue
|
| 460 |
+
seen.add(key)
|
| 461 |
+
out.append(t)
|
| 462 |
+
if len(out) >= limit:
|
| 463 |
+
break
|
| 464 |
+
return out
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
@app.get("/api/search")
|
| 468 |
+
def api_search():
|
| 469 |
+
q = (request.args.get("q") or "").strip()
|
| 470 |
+
limit = min(int(request.args.get("limit", 24)), 40)
|
| 471 |
+
if not q:
|
| 472 |
+
return jsonify(error="missing query"), 400
|
| 473 |
+
|
| 474 |
+
# Three YouTube Music calls, run in parallel — sequential they pushed search past 4 s.
|
| 475 |
+
def job_broad(yt):
|
| 476 |
+
return yt.search(q, limit=max(limit, 20)) or []
|
| 477 |
+
|
| 478 |
+
def job_songs(yt):
|
| 479 |
+
return yt.search(q, filter="songs", limit=max(limit, 24)) or []
|
| 480 |
+
|
| 481 |
+
def job_artists(yt):
|
| 482 |
+
return yt.search(q, filter="artists", limit=8) or []
|
| 483 |
+
|
| 484 |
+
t0 = time.time()
|
| 485 |
+
results = {}
|
| 486 |
+
|
| 487 |
+
def run(key, fn):
|
| 488 |
+
try:
|
| 489 |
+
results[key] = _yt_call_retry(fn)
|
| 490 |
+
except Exception as exc: # noqa: BLE001
|
| 491 |
+
results[key] = []
|
| 492 |
+
app.logger.warning("search %r (%s) failed: %s", q, key, exc)
|
| 493 |
+
|
| 494 |
+
threads = [threading.Thread(target=run, args=("broad", job_broad), daemon=True),
|
| 495 |
+
threading.Thread(target=run, args=("songs", job_songs), daemon=True),
|
| 496 |
+
threading.Thread(target=run, args=("artists", job_artists), daemon=True)]
|
| 497 |
+
for t in threads:
|
| 498 |
+
t.start()
|
| 499 |
+
for t in threads:
|
| 500 |
+
t.join(timeout=30)
|
| 501 |
+
|
| 502 |
+
broad = results.get("broad") or []
|
| 503 |
+
artists = results.get("artists") or []
|
| 504 |
+
|
| 505 |
+
filtered = results.get("songs") or []
|
| 506 |
+
|
| 507 |
+
merged, seen = [], set()
|
| 508 |
+
for r in broad + filtered:
|
| 509 |
+
if r.get("resultType") not in ("song", "video"):
|
| 510 |
+
continue
|
| 511 |
+
vid = r.get("videoId")
|
| 512 |
+
if not vid or vid in seen:
|
| 513 |
+
continue
|
| 514 |
+
seen.add(vid)
|
| 515 |
+
merged.append(r)
|
| 516 |
+
artists = artists + [r for r in broad if r.get("resultType") == "artist"]
|
| 517 |
+
rank_hint = {r.get("videoId"): i for i, r in enumerate(broad)}
|
| 518 |
+
|
| 519 |
+
if not merged:
|
| 520 |
+
return jsonify(query=q, tracks=[], artists=[], suggestions=[])
|
| 521 |
+
|
| 522 |
+
ranked = _rank_tracks(norm_tracks(merged), q, limit, rank_hint)
|
| 523 |
+
app.logger.info("search %r: %d results in %.2fs", q, len(ranked), time.time() - t0)
|
| 524 |
+
|
| 525 |
+
out_artists, seen_names = [], set()
|
| 526 |
+
for a in artists:
|
| 527 |
+
name = (a.get("artist") or a.get("title") or "").strip()
|
| 528 |
+
if not name or name.lower() in seen_names:
|
| 529 |
+
continue
|
| 530 |
+
seen_names.add(name.lower())
|
| 531 |
+
out_artists.append({
|
| 532 |
+
"name": name,
|
| 533 |
+
"id": a.get("browseId"),
|
| 534 |
+
"subscribers": a.get("subscribers"),
|
| 535 |
+
"thumbnail": pick_thumb(a.get("thumbnails"), 226),
|
| 536 |
+
})
|
| 537 |
+
for t in ranked:
|
| 538 |
+
for a in t.get("artists") or []:
|
| 539 |
+
name = (a.get("name") or "").strip()
|
| 540 |
+
if not name or name.lower() in seen_names:
|
| 541 |
+
continue
|
| 542 |
+
seen_names.add(name.lower())
|
| 543 |
+
out_artists.append({"name": name, "id": a.get("id"), "subscribers": None, "thumbnail": None})
|
| 544 |
+
|
| 545 |
+
suggestions = []
|
| 546 |
+
if not ranked:
|
| 547 |
+
try:
|
| 548 |
+
def sug(yt):
|
| 549 |
+
return yt.get_search_suggestions(q) or []
|
| 550 |
+
suggestions = [x for x in _yt_call_retry(sug) if isinstance(x, str)][:6]
|
| 551 |
+
except Exception: # noqa: BLE001
|
| 552 |
+
suggestions = []
|
| 553 |
+
|
| 554 |
+
return jsonify(query=q, tracks=ranked, artists=out_artists[:12] if ranked else [], suggestions=suggestions)
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
@app.get("/api/suggest")
|
| 558 |
+
def api_suggest():
|
| 559 |
+
q = (request.args.get("q") or "").strip()
|
| 560 |
+
if len(q) < 2:
|
| 561 |
+
return jsonify(suggestions=[])
|
| 562 |
+
try:
|
| 563 |
+
items = yt_call(lambda yt: yt.get_search_suggestions(q), timeout=15) or []
|
| 564 |
+
except Exception: # noqa: BLE001
|
| 565 |
+
items = []
|
| 566 |
+
return jsonify(suggestions=[s for s in items if isinstance(s, str)][:8])
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
@app.get("/api/radio")
|
| 570 |
+
def api_radio():
|
| 571 |
+
"""Tracks to play next: YouTube Music's radio / 'up next' for a seed track."""
|
| 572 |
+
vid = (request.args.get("videoId") or "").strip()
|
| 573 |
+
limit = min(int(request.args.get("limit", 25)), 50)
|
| 574 |
+
if not VIDEO_ID_RE.match(vid):
|
| 575 |
+
return jsonify(error="bad videoId"), 400
|
| 576 |
+
try:
|
| 577 |
+
data = yt_call(lambda yt: yt.get_watch_playlist(videoId=vid, limit=limit + 1))
|
| 578 |
+
except Exception as exc: # noqa: BLE001
|
| 579 |
+
return jsonify(error=f"radio failed: {exc}"), 502
|
| 580 |
+
tracks = [t for t in norm_tracks(data.get("tracks")) if t["video_id"] != vid]
|
| 581 |
+
return jsonify(seed=vid, tracks=tracks)
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
@app.get("/api/artist")
|
| 585 |
+
def api_artist():
|
| 586 |
+
"""Artist overview: top songs, albums, similar artists — by id or name."""
|
| 587 |
+
bid = (request.args.get("id") or "").strip()
|
| 588 |
+
name = (request.args.get("name") or "").strip()
|
| 589 |
+
|
| 590 |
+
def job(yt):
|
| 591 |
+
nonlocal bid
|
| 592 |
+
if not bid and name:
|
| 593 |
+
found = yt.search(name, filter="artists", limit=1) or []
|
| 594 |
+
if found:
|
| 595 |
+
bid = found[0].get("browseId")
|
| 596 |
+
if not bid:
|
| 597 |
+
raise LookupError("artist not found")
|
| 598 |
+
art = yt.get_artist(bid)
|
| 599 |
+
top = []
|
| 600 |
+
songs_section = art.get("songs") or {}
|
| 601 |
+
if songs_section.get("browseId"):
|
| 602 |
+
try:
|
| 603 |
+
pl = yt.get_playlist(songs_section["browseId"], limit=25)
|
| 604 |
+
top = pl.get("tracks") or []
|
| 605 |
+
except Exception: # noqa: BLE001
|
| 606 |
+
top = songs_section.get("results") or []
|
| 607 |
+
return art, top
|
| 608 |
+
|
| 609 |
+
try:
|
| 610 |
+
art, top = yt_call(job)
|
| 611 |
+
except LookupError as exc:
|
| 612 |
+
return jsonify(error=str(exc)), 404
|
| 613 |
+
except Exception as exc: # noqa: BLE001
|
| 614 |
+
return jsonify(error=f"artist lookup failed: {exc}"), 502
|
| 615 |
+
|
| 616 |
+
albums = []
|
| 617 |
+
for key in ("albums", "singles"):
|
| 618 |
+
for al in (art.get(key) or {}).get("results") or []:
|
| 619 |
+
albums.append(
|
| 620 |
+
{
|
| 621 |
+
"id": al.get("browseId"),
|
| 622 |
+
"title": al.get("title"),
|
| 623 |
+
"year": al.get("year"),
|
| 624 |
+
"type": "Single" if key == "singles" else "Album",
|
| 625 |
+
}
|
| 626 |
+
)
|
| 627 |
+
|
| 628 |
+
return jsonify(
|
| 629 |
+
id=art.get("channelId") or bid,
|
| 630 |
+
name=art.get("name"),
|
| 631 |
+
description=(art.get("description") or "")[:600],
|
| 632 |
+
subscribers=art.get("subscribers"),
|
| 633 |
+
thumbnail=pick_thumb(art.get("thumbnails"), 300),
|
| 634 |
+
monthly_listeners=art.get("monthlyListeners"),
|
| 635 |
+
top_tracks=norm_tracks(top),
|
| 636 |
+
albums=albums[:12],
|
| 637 |
+
similar=[
|
| 638 |
+
{"name": r.get("title"), "id": r.get("browseId")}
|
| 639 |
+
for r in (art.get("related") or {}).get("results") or []
|
| 640 |
+
if r.get("browseId")
|
| 641 |
+
][:12],
|
| 642 |
+
)
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
@app.get("/api/artist/radio")
|
| 646 |
+
def api_artist_radio():
|
| 647 |
+
"""Artist radio station — a continuous mix seeded by the artist."""
|
| 648 |
+
bid = (request.args.get("id") or "").strip()
|
| 649 |
+
limit = min(int(request.args.get("limit", 30)), 50)
|
| 650 |
+
if not SAFE_BROWSE_RE.match(bid):
|
| 651 |
+
return jsonify(error="bad artist id"), 400
|
| 652 |
+
|
| 653 |
+
def job(yt):
|
| 654 |
+
art = yt.get_artist(bid)
|
| 655 |
+
radio_id = art.get("radioId") or art.get("shuffleId")
|
| 656 |
+
if not radio_id:
|
| 657 |
+
raise LookupError("this artist has no radio station")
|
| 658 |
+
data = yt.get_watch_playlist(playlistId=radio_id, limit=limit)
|
| 659 |
+
return art.get("name"), norm_tracks(data.get("tracks"))
|
| 660 |
+
|
| 661 |
+
try:
|
| 662 |
+
name, tracks = yt_call(job)
|
| 663 |
+
except LookupError as exc:
|
| 664 |
+
return jsonify(error=str(exc)), 404
|
| 665 |
+
except Exception as exc: # noqa: BLE001
|
| 666 |
+
return jsonify(error=f"radio failed: {exc}"), 502
|
| 667 |
+
return jsonify(station_name=name, tracks=tracks)
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
@app.get("/api/album")
|
| 671 |
+
def api_album():
|
| 672 |
+
bid = (request.args.get("id") or "").strip()
|
| 673 |
+
if not SAFE_BROWSE_RE.match(bid):
|
| 674 |
+
return jsonify(error="bad album id"), 400
|
| 675 |
+
try:
|
| 676 |
+
alb = yt_call(lambda yt: yt.get_album(bid))
|
| 677 |
+
except Exception as exc: # noqa: BLE001
|
| 678 |
+
return jsonify(error=f"album lookup failed: {exc}"), 502
|
| 679 |
+
return jsonify(
|
| 680 |
+
id=alb.get("audioPlaylistId") or bid,
|
| 681 |
+
title=alb.get("title"),
|
| 682 |
+
year=alb.get("year"),
|
| 683 |
+
tracks=norm_tracks(alb.get("tracks")),
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
|
| 687 |
+
# ── dashboard panels ────────────────────────────────────────────────────────
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
@app.get("/api/trending")
|
| 691 |
+
def api_trending():
|
| 692 |
+
"""Trending tracks + artists for a country (YouTube Music charts)."""
|
| 693 |
+
country = (request.args.get("country") or "CH").upper()[:2]
|
| 694 |
+
limit = min(int(request.args.get("limit", 12)), 30)
|
| 695 |
+
|
| 696 |
+
def job(yt):
|
| 697 |
+
charts = yt.get_charts(country=country)
|
| 698 |
+
vids = charts.get("videos") or []
|
| 699 |
+
artists = charts.get("artists") or []
|
| 700 |
+
tracks = []
|
| 701 |
+
if vids:
|
| 702 |
+
try:
|
| 703 |
+
pl = yt.get_playlist(vids[0]["playlistId"], limit=limit)
|
| 704 |
+
tracks = pl.get("tracks") or []
|
| 705 |
+
except Exception: # noqa: BLE001
|
| 706 |
+
tracks = []
|
| 707 |
+
if not tracks:
|
| 708 |
+
# fall back to the trending chart for the whole world
|
| 709 |
+
zz = yt.get_charts(country="ZZ")
|
| 710 |
+
zv = zz.get("videos") or []
|
| 711 |
+
if zv:
|
| 712 |
+
try:
|
| 713 |
+
pl = yt.get_playlist(zv[0]["playlistId"], limit=limit)
|
| 714 |
+
tracks = pl.get("tracks") or []
|
| 715 |
+
except Exception: # noqa: BLE001
|
| 716 |
+
tracks = []
|
| 717 |
+
artists = artists or zz.get("artists") or []
|
| 718 |
+
return charts, tracks, artists
|
| 719 |
+
|
| 720 |
+
try:
|
| 721 |
+
charts, tracks, artists = yt_call(job)
|
| 722 |
+
except Exception as exc: # noqa: BLE001
|
| 723 |
+
return jsonify(error=f"trending failed: {exc}"), 502
|
| 724 |
+
|
| 725 |
+
selected = (charts.get("countries") or {}).get("selected") or {}
|
| 726 |
+
return jsonify(
|
| 727 |
+
country=country,
|
| 728 |
+
country_name=selected.get("text") or country,
|
| 729 |
+
countries=(charts.get("countries") or {}).get("options") or [country],
|
| 730 |
+
tracks=norm_tracks(tracks)[:limit],
|
| 731 |
+
artists=[
|
| 732 |
+
{
|
| 733 |
+
"name": a.get("title"),
|
| 734 |
+
"id": a.get("browseId"),
|
| 735 |
+
"subscribers": a.get("subscribers"),
|
| 736 |
+
"rank": a.get("rank"),
|
| 737 |
+
"trend": a.get("trend"),
|
| 738 |
+
"thumbnail": pick_thumb(a.get("thumbnails"), 120),
|
| 739 |
+
}
|
| 740 |
+
for a in artists[:limit]
|
| 741 |
+
],
|
| 742 |
+
)
|
| 743 |
+
|
| 744 |
+
|
| 745 |
+
@app.get("/api/genres")
|
| 746 |
+
def api_genres():
|
| 747 |
+
"""Browse tree: moods & moments + genres."""
|
| 748 |
+
try:
|
| 749 |
+
cats = yt_call(lambda yt: yt.get_mood_categories())
|
| 750 |
+
except Exception as exc: # noqa: BLE001
|
| 751 |
+
return jsonify(error=f"genres failed: {exc}"), 502
|
| 752 |
+
return jsonify(
|
| 753 |
+
categories=[
|
| 754 |
+
{
|
| 755 |
+
"title": title,
|
| 756 |
+
"items": [{"title": x.get("title"), "params": x.get("params")} for x in items if x.get("params")],
|
| 757 |
+
}
|
| 758 |
+
for title, items in (cats or {}).items()
|
| 759 |
+
]
|
| 760 |
+
)
|
| 761 |
+
|
| 762 |
+
|
| 763 |
+
@app.get("/api/genre")
|
| 764 |
+
def api_genre():
|
| 765 |
+
"""Playlists inside one mood/genre tile."""
|
| 766 |
+
params = (request.args.get("params") or "").strip()
|
| 767 |
+
if not SAFE_BROWSE_RE.match(params):
|
| 768 |
+
return jsonify(error="bad params"), 400
|
| 769 |
+
try:
|
| 770 |
+
playlists = yt_call(lambda yt: yt.get_mood_playlists(params)) or []
|
| 771 |
+
except Exception as exc: # noqa: BLE001
|
| 772 |
+
return jsonify(error=f"genre failed: {exc}"), 502
|
| 773 |
+
return jsonify(
|
| 774 |
+
playlists=[
|
| 775 |
+
{
|
| 776 |
+
"title": p.get("title"),
|
| 777 |
+
"id": p.get("playlistId"),
|
| 778 |
+
"thumbnail": pick_thumb(p.get("thumbnails"), 226),
|
| 779 |
+
"count": p.get("count"),
|
| 780 |
+
}
|
| 781 |
+
for p in playlists
|
| 782 |
+
if p.get("playlistId")
|
| 783 |
+
]
|
| 784 |
+
)
|
| 785 |
+
|
| 786 |
+
|
| 787 |
+
@app.get("/api/playlist")
|
| 788 |
+
def api_playlist():
|
| 789 |
+
"""Track list for a YouTube Music playlist (mixes, genre playlists, charts)."""
|
| 790 |
+
pid = (request.args.get("id") or "").strip()
|
| 791 |
+
if not SAFE_BROWSE_RE.match(pid):
|
| 792 |
+
return jsonify(error="bad playlist id"), 400
|
| 793 |
+
limit = min(int(request.args.get("limit", 80)), 150)
|
| 794 |
+
try:
|
| 795 |
+
pl = yt_call(lambda yt: yt.get_playlist(pid, limit=limit))
|
| 796 |
+
except Exception as exc: # noqa: BLE001
|
| 797 |
+
return jsonify(error=f"playlist failed: {exc}"), 502
|
| 798 |
+
return jsonify(
|
| 799 |
+
id=pid,
|
| 800 |
+
title=pl.get("title"),
|
| 801 |
+
author=(pl.get("author") or {}).get("name") if isinstance(pl.get("author"), dict) else pl.get("author"),
|
| 802 |
+
track_count=pl.get("trackCount"),
|
| 803 |
+
tracks=norm_tracks(pl.get("tracks")),
|
| 804 |
+
)
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
@app.get("/api/stats")
|
| 808 |
+
def api_stats():
|
| 809 |
+
"""Engine telemetry for the dashboard's system panel."""
|
| 810 |
+
files = [f for f in AUDIO_DIR.glob("*.*") if f.suffix != ".part"]
|
| 811 |
+
with _stats_lock:
|
| 812 |
+
snap = dict(_stats)
|
| 813 |
+
return jsonify(
|
| 814 |
+
cached_tracks=len(files),
|
| 815 |
+
cached_bytes=sum(f.stat().st_size for f in files),
|
| 816 |
+
downloads=snap["downloads"],
|
| 817 |
+
downloads_inflight=snap["inflight"],
|
| 818 |
+
download_failures=snap["failures"],
|
| 819 |
+
avg_download_seconds=round(snap["download_seconds"] / snap["downloads"], 2) if snap["downloads"] else None,
|
| 820 |
+
bytes_served=snap["bytes_served"],
|
| 821 |
+
uptime_seconds=round(time.time() - ENGINE_STARTED, 1),
|
| 822 |
+
cache_limit_bytes=CACHE_MAX_BYTES,
|
| 823 |
+
)
|
| 824 |
+
|
| 825 |
+
|
| 826 |
+
@app.get("/api/ready/<video_id>")
|
| 827 |
+
def api_ready(video_id: str):
|
| 828 |
+
"""Is this track already buffered on the server? (drives the 'ready ahead' stat)"""
|
| 829 |
+
if not VIDEO_ID_RE.match(video_id):
|
| 830 |
+
abort(400)
|
| 831 |
+
p = cached_audio_path(video_id)
|
| 832 |
+
return jsonify(video_id=video_id, cached=bool(p), bytes=p.stat().st_size if p else 0)
|
| 833 |
+
|
| 834 |
+
|
| 835 |
+
@app.get("/classic")
|
| 836 |
+
def classic():
|
| 837 |
+
"""The original single-column player UI, kept for reference."""
|
| 838 |
+
return render_template("index.html")
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
@app.route("/api/stream/<video_id>", methods=["GET", "HEAD"])
|
| 842 |
+
def api_stream(video_id: str):
|
| 843 |
+
if not VIDEO_ID_RE.match(video_id):
|
| 844 |
+
abort(400)
|
| 845 |
+
started = time.time()
|
| 846 |
+
try:
|
| 847 |
+
path = download_audio(video_id)
|
| 848 |
+
except StreamBlocked as exc:
|
| 849 |
+
app.logger.warning("YouTube blocked media fetch for %s: %s", video_id, exc)
|
| 850 |
+
return jsonify(
|
| 851 |
+
error="YouTube refused to serve this audio to the server's IP address.",
|
| 852 |
+
code="youtube_blocked",
|
| 853 |
+
hint=(
|
| 854 |
+
"Search, charts and suggestions still work. Playback needs a network YouTube "
|
| 855 |
+
"doesn't rate-limit — run the app on your own machine/home connection, or use a "
|
| 856 |
+
"residential VPN. Datacenter/cloud IPs are blocked most often."
|
| 857 |
+
),
|
| 858 |
+
upstream=str(exc),
|
| 859 |
+
), 503
|
| 860 |
+
except DownloadError as exc:
|
| 861 |
+
app.logger.warning("download failed for %s: %s", video_id, exc)
|
| 862 |
+
return jsonify(error=str(exc), code="download_failed"), 502
|
| 863 |
+
with _stats_lock:
|
| 864 |
+
_stats["bytes_served"] += path.stat().st_size
|
| 865 |
+
app.logger.info("serving %s (%s) after %.2fs", video_id, path.name, time.time() - started)
|
| 866 |
+
mime = MIME.get(path.suffix, mimetypes.guess_type(path.name)[0] or "application/octet-stream")
|
| 867 |
+
resp = send_file(path, mimetype=mime, conditional=True, etag=True)
|
| 868 |
+
resp.headers["Cache-Control"] = "public, max-age=86400"
|
| 869 |
+
return resp
|
| 870 |
+
|
| 871 |
+
|
| 872 |
+
@app.post("/api/prefetch/<video_id>")
|
| 873 |
+
def api_prefetch(video_id: str):
|
| 874 |
+
if not VIDEO_ID_RE.match(video_id):
|
| 875 |
+
abort(400)
|
| 876 |
+
prefetch_audio(video_id)
|
| 877 |
+
return jsonify(ok=True)
|
| 878 |
+
|
| 879 |
+
|
| 880 |
+
_IMG_HOSTS = (".googleusercontent.com", ".ggpht.com", ".ytimg.com")
|
| 881 |
+
|
| 882 |
+
|
| 883 |
+
@app.get("/api/img")
|
| 884 |
+
def api_img():
|
| 885 |
+
"""Proxy external images (artist photos) so the UI works behind any sandbox."""
|
| 886 |
+
url = request.args.get("u") or ""
|
| 887 |
+
host = url.split("/")[2].split(":")[0] if url.startswith("https://") and "/" in url[8:] else ""
|
| 888 |
+
if not host or not host.endswith(_IMG_HOSTS):
|
| 889 |
+
abort(400)
|
| 890 |
+
key = hashlib.sha1((url + "|v1").encode()).hexdigest()[:24]
|
| 891 |
+
cached = next(IMG_DIR.glob(f"{key}.*"), None)
|
| 892 |
+
if cached is None:
|
| 893 |
+
try:
|
| 894 |
+
r = requests.get(url, timeout=12, headers={"User-Agent": "Mozilla/5.0 (Cadence)"})
|
| 895 |
+
r.raise_for_status()
|
| 896 |
+
except Exception: # noqa: BLE001
|
| 897 |
+
abort(502)
|
| 898 |
+
ext = mimetypes.guess_extension(r.headers.get("Content-Type", "").split(";")[0]) or ".jpg"
|
| 899 |
+
cached = IMG_DIR / f"{key}{ext}"
|
| 900 |
+
cached.write_bytes(r.content)
|
| 901 |
+
return send_file(cached, max_age=86400)
|
| 902 |
+
|
| 903 |
+
|
| 904 |
+
@app.get("/api/thumb/<video_id>")
|
| 905 |
+
def api_thumb(video_id: str):
|
| 906 |
+
"""Track artwork via the same image proxy (16:9 thumbnail from YouTube)."""
|
| 907 |
+
if not VIDEO_ID_RE.match(video_id):
|
| 908 |
+
abort(400)
|
| 909 |
+
return api_img_redirect(f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg")
|
| 910 |
+
|
| 911 |
+
|
| 912 |
+
def api_img_redirect(url: str):
|
| 913 |
+
key = hashlib.sha1((url + "|v1").encode()).hexdigest()[:24]
|
| 914 |
+
cached = next(IMG_DIR.glob(f"{key}.*"), None)
|
| 915 |
+
if cached is None:
|
| 916 |
+
try:
|
| 917 |
+
r = requests.get(url, timeout=12, headers={"User-Agent": "Mozilla/5.0 (Cadence)"})
|
| 918 |
+
if r.status_code == 200 and r.content:
|
| 919 |
+
cached = IMG_DIR / f"{key}.jpg"
|
| 920 |
+
cached.write_bytes(r.content)
|
| 921 |
+
except Exception: # noqa: BLE001
|
| 922 |
+
pass
|
| 923 |
+
if cached is None:
|
| 924 |
+
# 1x1 transparent gif fallback
|
| 925 |
+
import base64
|
| 926 |
+
|
| 927 |
+
gif = base64.b64decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7")
|
| 928 |
+
from flask import Response
|
| 929 |
+
|
| 930 |
+
return Response(gif, mimetype="image/gif", status=200)
|
| 931 |
+
return send_file(cached, max_age=86400)
|
| 932 |
+
|
| 933 |
+
|
| 934 |
+
_selfcheck()
|
| 935 |
+
_evict_cache()
|
| 936 |
+
|
| 937 |
+
if __name__ == "__main__":
|
| 938 |
+
print(f"🎧 Cadence running on http://0.0.0.0:{PORT}")
|
| 939 |
+
app.run(host="0.0.0.0", port=PORT, threaded=True, debug=False)
|
fly.toml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Fly.io — deploy with: fly launch --copy-config --name <your-app> --region zrh
|
| 2 |
+
# then attach storage: fly volumes create cadence_cache --size 5 --region zrh
|
| 3 |
+
app = "cadence"
|
| 4 |
+
primary_region = "zrh" # Zurich; other options: fra, ams, lhr
|
| 5 |
+
|
| 6 |
+
[build]
|
| 7 |
+
dockerfile = "Dockerfile"
|
| 8 |
+
|
| 9 |
+
[env]
|
| 10 |
+
PORT = "8080"
|
| 11 |
+
CADENCE_CACHE = "/data"
|
| 12 |
+
CADENCE_MAX_CACHE_BYTES = "3221225472"
|
| 13 |
+
|
| 14 |
+
[http_service]
|
| 15 |
+
internal_port = 8080
|
| 16 |
+
force_https = true
|
| 17 |
+
auto_stop_machines = "stop" # scale to zero when idle; first request wakes it
|
| 18 |
+
auto_start_machines = true
|
| 19 |
+
min_machines_running = 0
|
| 20 |
+
processes = ["app"]
|
| 21 |
+
|
| 22 |
+
[[http_service.checks]]
|
| 23 |
+
interval = "30s"
|
| 24 |
+
timeout = "5s"
|
| 25 |
+
grace_period = "20s"
|
| 26 |
+
method = "GET"
|
| 27 |
+
path = "/healthz"
|
| 28 |
+
|
| 29 |
+
[[mounts]]
|
| 30 |
+
source = "cadence_cache"
|
| 31 |
+
destination = "/data"
|
| 32 |
+
|
| 33 |
+
[[vm]]
|
| 34 |
+
size = "shared-cpu-1x"
|
| 35 |
+
memory = "512mb"
|
railway.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://railway.app/railway.schema.json",
|
| 3 |
+
"build": {
|
| 4 |
+
"builder": "DOCKERFILE",
|
| 5 |
+
"dockerfilePath": "Dockerfile"
|
| 6 |
+
},
|
| 7 |
+
"deploy": {
|
| 8 |
+
"startCommand": "gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 16 --timeout 180 app:app",
|
| 9 |
+
"healthcheckPath": "/healthz",
|
| 10 |
+
"healthcheckTimeout": 120,
|
| 11 |
+
"restartPolicyType": "ON_FAILURE",
|
| 12 |
+
"restartPolicyMaxRetries": 5,
|
| 13 |
+
"numReplicas": 1
|
| 14 |
+
}
|
| 15 |
+
}
|
render.yaml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Render blueprint — https://render.com/docs/blueprint-spec
|
| 2 |
+
# Deploy: push this repo to GitHub, then Render → New → Blueprint → pick the repo.
|
| 3 |
+
services:
|
| 4 |
+
- type: web
|
| 5 |
+
name: cadence
|
| 6 |
+
runtime: docker
|
| 7 |
+
plan: starter # free tier spins down after 15 min idle (slow first hit)
|
| 8 |
+
region: frankfurt # closest to Switzerland; also: oregon, ohio, singapore, virginia
|
| 9 |
+
dockerfilePath: ./Dockerfile
|
| 10 |
+
healthCheckPath: /healthz
|
| 11 |
+
autoDeploy: true
|
| 12 |
+
envVars:
|
| 13 |
+
- key: PORT
|
| 14 |
+
value: 10000
|
| 15 |
+
- key: CADENCE_CACHE
|
| 16 |
+
value: /data
|
| 17 |
+
- key: CADENCE_MAX_CACHE_BYTES
|
| 18 |
+
value: "3221225472" # 3 GB
|
| 19 |
+
- key: PYTHONUNBUFFERED
|
| 20 |
+
value: "1"
|
| 21 |
+
disk:
|
| 22 |
+
name: cadence-cache
|
| 23 |
+
mountPath: /data
|
| 24 |
+
sizeGB: 5 # audio cache survives restarts and redeploys
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask>=3.0
|
| 2 |
+
gunicorn>=21.2
|
| 3 |
+
yt-dlp>=2025.1.1
|
| 4 |
+
ytmusicapi>=1.10
|
| 5 |
+
requests>=2.31
|
run.sh
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Cadence — install deps (if needed) and start the dashboard on http://localhost:8000
|
| 3 |
+
#
|
| 4 |
+
# bash run.sh → port 8000
|
| 5 |
+
# PORT=8010 bash run.sh → custom port
|
| 6 |
+
#
|
| 7 |
+
# If the browser says "not answering on 127.0.0.1:8000", this script tells you why
|
| 8 |
+
# instead of exiting quietly.
|
| 9 |
+
set -uo pipefail
|
| 10 |
+
cd "$(dirname "$0")"
|
| 11 |
+
|
| 12 |
+
PORT="${PORT:-8000}"
|
| 13 |
+
|
| 14 |
+
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
|
| 15 |
+
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
|
| 16 |
+
bad() { printf ' \033[31m✗\033[0m %s\n' "$*"; }
|
| 17 |
+
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
|
| 18 |
+
|
| 19 |
+
die() { bad "$1"; shift; for line in "$@"; do echo " $line"; done; exit 1; }
|
| 20 |
+
|
| 21 |
+
# ── 1. python itself ─────────────────────────────────────────────────────────
|
| 22 |
+
if ! command -v python3 >/dev/null 2>&1; then
|
| 23 |
+
die "python3 is not installed." \
|
| 24 |
+
"macOS: brew install python (or install from python.org)" \
|
| 25 |
+
"Debian/Ubuntu: sudo apt install python3 python3-venv python3-pip"
|
| 26 |
+
fi
|
| 27 |
+
|
| 28 |
+
PYV="$(python3 -c 'import sys;print("%d.%d"%sys.version_info[:2])' 2>/dev/null || echo "?")"
|
| 29 |
+
case "$PYV" in
|
| 30 |
+
3.9|3.1[0-9]|3.[2-9][0-9]|[4-9].*) ok "python $PYV" ;;
|
| 31 |
+
*) die "python $PYV is too old — Cadence needs 3.9 or newer." \
|
| 32 |
+
"Install a newer python3, then run: bash run.sh" ;;
|
| 33 |
+
esac
|
| 34 |
+
|
| 35 |
+
# ── 2. is the port already taken? ────────────────────────────────────────────
|
| 36 |
+
port_busy() {
|
| 37 |
+
if command -v ss >/dev/null 2>&1; then ss -ltn 2>/dev/null | grep -q ":${PORT} "; return $?; fi
|
| 38 |
+
if command -v lsof >/dev/null 2>&1; then lsof -iTCP:"${PORT}" -sTCP:LISTEN >/dev/null 2>&1; return $?; fi
|
| 39 |
+
return 1
|
| 40 |
+
}
|
| 41 |
+
if port_busy; then
|
| 42 |
+
# A Cadence backend already listening is fine — say so, don't start a second one.
|
| 43 |
+
if curl -fsS -m 3 "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1; then
|
| 44 |
+
ok "Cadence is already running on http://localhost:${PORT}"
|
| 45 |
+
echo
|
| 46 |
+
echo " Open http://localhost:${PORT} — nothing else to do."
|
| 47 |
+
echo " (Stop the old one first if you want a restart.)"
|
| 48 |
+
exit 0
|
| 49 |
+
fi
|
| 50 |
+
die "port ${PORT} is already used by another program." \
|
| 51 |
+
"Start Cadence on a different port: PORT=8010 bash run.sh" \
|
| 52 |
+
"See what holds it: lsof -iTCP:${PORT} -sTCP:LISTEN (or: ss -ltnp | grep ${PORT})"
|
| 53 |
+
fi
|
| 54 |
+
|
| 55 |
+
# ── 3. dependencies ──────────────────────────────────────────────────────────
|
| 56 |
+
have_deps() { "$1" -c 'import flask, yt_dlp, ytmusicapi, requests' >/dev/null 2>&1; }
|
| 57 |
+
|
| 58 |
+
PY=python3
|
| 59 |
+
if have_deps python3; then
|
| 60 |
+
ok "dependencies already available"
|
| 61 |
+
else
|
| 62 |
+
bold "Installing dependencies (first run only)"
|
| 63 |
+
if python3 -m pip install -q -r requirements.txt >/dev/null 2>&1 && have_deps python3; then
|
| 64 |
+
ok "installed into system python"
|
| 65 |
+
else
|
| 66 |
+
# PEP 668 "externally managed" systems (Debian, Ubuntu, Homebrew) refuse system pip
|
| 67 |
+
warn "system pip declined — using a local virtualenv in ./.venv"
|
| 68 |
+
if [ ! -d .venv ]; then
|
| 69 |
+
python3 -m venv .venv >/dev/null 2>&1 || die "could not create .venv" \
|
| 70 |
+
"Debian/Ubuntu: sudo apt install python3-venv then re-run: bash run.sh" \
|
| 71 |
+
"macOS: brew install python then re-run: bash run.sh"
|
| 72 |
+
fi
|
| 73 |
+
./.venv/bin/pip install -q -r requirements.txt || die "pip install failed" \
|
| 74 |
+
"Check your internet connection, then re-run: bash run.sh" \
|
| 75 |
+
"Behind a proxy? export HTTPS_PROXY=http://host:port"
|
| 76 |
+
have_deps ./.venv/bin/python || die "dependencies still missing after install" \
|
| 77 |
+
"Try: rm -rf .venv && bash run.sh"
|
| 78 |
+
ok "installed into ./.venv"
|
| 79 |
+
PY=./.venv/bin/python
|
| 80 |
+
fi
|
| 81 |
+
fi
|
| 82 |
+
|
| 83 |
+
# ── 4. start ─────────────────────────────────────────────────────────────────
|
| 84 |
+
bold "Starting Cadence on http://localhost:${PORT}"
|
| 85 |
+
echo " (Ctrl+C stops it)"
|
| 86 |
+
echo
|
| 87 |
+
PORT="$PORT" exec "$PY" app.py
|
standalone/build_standalone.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Build the standalone single-file dashboard.
|
| 3 |
+
|
| 4 |
+
Reads the Flask templates/CSS/JS and fuses them into one self-contained .html:
|
| 5 |
+
no server, no external requests, works from file://, a static host, or an e-mail.
|
| 6 |
+
|
| 7 |
+
python3 standalone/build_standalone.py → ../cadence-dashboard.html
|
| 8 |
+
"""
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
import re
|
| 11 |
+
import sys
|
| 12 |
+
|
| 13 |
+
HERE = Path(__file__).resolve().parent
|
| 14 |
+
ROOT = HERE.parent # /home/user/cadence
|
| 15 |
+
OUT = ROOT.parent / "cadence-dashboard.html"
|
| 16 |
+
|
| 17 |
+
html = (ROOT / "templates" / "dashboard.html").read_text()
|
| 18 |
+
css = (ROOT / "static" / "dash.css").read_text()
|
| 19 |
+
dash = (ROOT / "static" / "dash.js").read_text()
|
| 20 |
+
runtime = (HERE / "runtime.js").read_text()
|
| 21 |
+
|
| 22 |
+
# ── 1. dashboard.js → reroute every network touchpoint through the runtime ──
|
| 23 |
+
subs = [
|
| 24 |
+
# data source
|
| 25 |
+
("""async function api(path, opts) {
|
| 26 |
+
const r = await fetch(path, opts);
|
| 27 |
+
let data = null; try { data = await r.json(); } catch {}
|
| 28 |
+
if (!r.ok) {
|
| 29 |
+
const err = new Error((data && data.error) || `Request failed (${r.status})`);
|
| 30 |
+
err.status = r.status; err.code = data && data.code; err.hint = data && data.hint;
|
| 31 |
+
throw err;
|
| 32 |
+
}
|
| 33 |
+
return data;
|
| 34 |
+
}""",
|
| 35 |
+
"""async function api(path, opts) { return window.__SRC__.api(path, opts); }"""),
|
| 36 |
+
|
| 37 |
+
# artwork
|
| 38 |
+
("const thumb = vid => `/api/thumb/${vid}`;",
|
| 39 |
+
"const thumb = vid => window.__SRC__.thumb(vid);"),
|
| 40 |
+
("const imgp = u => `/api/img?u=${encodeURIComponent(u)}`;",
|
| 41 |
+
"const imgp = u => window.__SRC__.img(u);"),
|
| 42 |
+
|
| 43 |
+
# the media element (real <audio> when connected, virtual clock in demo)
|
| 44 |
+
("const audio = $('#audio');",
|
| 45 |
+
"const audio = window.__SRC__.makeAudio($('#audio'));"),
|
| 46 |
+
|
| 47 |
+
# stream diagnostics
|
| 48 |
+
("""async function diagnoseStream(id) {
|
| 49 |
+
try {
|
| 50 |
+
const r = await fetch(`/api/stream/${id}`, { headers: { Range: 'bytes=0-1' } });
|
| 51 |
+
if (r.ok) return { kind: 'transient' };
|
| 52 |
+
let d = {}; try { d = await r.json(); } catch {}
|
| 53 |
+
return { kind: d.code || 'unknown', message: d.error, hint: d.hint };
|
| 54 |
+
} catch {
|
| 55 |
+
return { kind: 'offline' };
|
| 56 |
+
}
|
| 57 |
+
}""",
|
| 58 |
+
"""async function diagnoseStream(id) { return window.__SRC__.diagnose(id); }"""),
|
| 59 |
+
|
| 60 |
+
# prefetch
|
| 61 |
+
(" try { await fetch(`/api/prefetch/${nxt.video_id}`, { method: 'POST' }); } catch {}",
|
| 62 |
+
" await window.__SRC__.prefetch(nxt.video_id);"),
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
missing = []
|
| 66 |
+
for old, new in subs:
|
| 67 |
+
if old in dash:
|
| 68 |
+
dash = dash.replace(old, new)
|
| 69 |
+
else:
|
| 70 |
+
missing.append(old.splitlines()[0][:70])
|
| 71 |
+
if missing:
|
| 72 |
+
sys.exit("✗ anchors not found in dash.js:\n - " + "\n - ".join(missing))
|
| 73 |
+
|
| 74 |
+
# demo mode is offline by nature: don't pretend a backend is missing
|
| 75 |
+
dash = dash.replace(
|
| 76 |
+
" toast('Backend not reachable — start it with ./run.sh (search, streaming and suggestions need it)', true, 9000);",
|
| 77 |
+
" if (window.__SRC__.mode === 'demo') return;\n"
|
| 78 |
+
" toast('Backend not reachable — open ⚙ Settings to fix the URL or switch to Demo mode', true, 9000);")
|
| 79 |
+
|
| 80 |
+
dash = dash.replace(
|
| 81 |
+
"function reportBlocked(info) {\n if (blockingReported) return;",
|
| 82 |
+
"function reportBlocked(info) {\n if (blockingReported || window.__SRC__.mode === 'demo') return;")
|
| 83 |
+
|
| 84 |
+
# ── 2. fold CSS in, add the standalone-only styling ────────────────────────
|
| 85 |
+
extra_css = """
|
| 86 |
+
/* ── standalone build: settings + mode pill ─────────────────────────────── */
|
| 87 |
+
.mode-pill{margin-left:auto;display:inline-flex;align-items:center;gap:6px;height:34px;padding:0 12px;
|
| 88 |
+
border-radius:999px;border:1px solid var(--line);background:rgba(255,255,255,.04);color:var(--dim);
|
| 89 |
+
font:inherit;font-size:12px;font-weight:600;cursor:pointer;letter-spacing:.2px}
|
| 90 |
+
.mode-pill:hover{color:var(--fg);border-color:rgba(255,255,255,.22)}
|
| 91 |
+
.mode-pill.demo{color:#fbbf24;border-color:rgba(251,191,36,.35);background:rgba(251,191,36,.09)}
|
| 92 |
+
.mode-pill.live{color:#4ade80;border-color:rgba(74,222,128,.35);background:rgba(74,222,128,.09)}
|
| 93 |
+
.cfg-overlay{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;
|
| 94 |
+
padding:20px;background:rgba(6,8,14,.72);backdrop-filter:blur(10px)}
|
| 95 |
+
.cfg-overlay[hidden]{display:none} /* a class-level display: would otherwise defeat [hidden] */
|
| 96 |
+
.cfg-card{width:min(560px,100%);background:linear-gradient(180deg,rgba(26,30,44,.98),rgba(16,19,29,.98));
|
| 97 |
+
border:1px solid rgba(255,255,255,.12);border-radius:18px;padding:26px;box-shadow:0 30px 80px rgba(0,0,0,.6)}
|
| 98 |
+
.cfg-card h2{margin:0 0 6px;font-size:19px}
|
| 99 |
+
.cfg-sub{margin:0 0 18px;color:var(--dim);font-size:13px;line-height:1.55}
|
| 100 |
+
.cfg-opt{display:flex;gap:11px;align-items:flex-start;padding:13px 14px;margin-bottom:9px;cursor:pointer;
|
| 101 |
+
border:1px solid var(--line);border-radius:12px;background:rgba(255,255,255,.02)}
|
| 102 |
+
.cfg-opt:has(input:checked){border-color:rgba(139,92,246,.55);background:rgba(139,92,246,.10)}
|
| 103 |
+
.cfg-opt input{margin-top:3px;accent-color:#8b5cf6}
|
| 104 |
+
.cfg-opt span{display:block;font-size:13.5px;font-weight:600;line-height:1.4}
|
| 105 |
+
.cfg-opt em{display:block;font-style:normal;font-weight:400;font-size:12px;color:var(--dim);margin-top:3px}
|
| 106 |
+
.cfg-url{width:100%;margin:6px 0 10px;padding:11px 13px;border-radius:11px;border:1px solid var(--line);
|
| 107 |
+
background:rgba(0,0,0,.35);color:var(--fg);font:inherit;font-size:13px}
|
| 108 |
+
.cfg-url:focus{outline:none;border-color:rgba(139,92,246,.6)}
|
| 109 |
+
.cfg-note{font-size:12px;color:var(--dim);line-height:1.6;margin-bottom:16px}
|
| 110 |
+
.cfg-note.ok{color:#4ade80}.cfg-note.err{color:#f87171}
|
| 111 |
+
.cfg-note code{background:rgba(255,255,255,.08);padding:1px 5px;border-radius:5px;font-size:11.5px}
|
| 112 |
+
.cfg-actions{display:flex;gap:10px;justify-content:flex-end}
|
| 113 |
+
.cfg-btn{height:38px;padding:0 16px;border-radius:10px;border:1px solid var(--line);background:rgba(255,255,255,.05);
|
| 114 |
+
color:var(--fg);font:inherit;font-size:13px;font-weight:600;cursor:pointer}
|
| 115 |
+
.cfg-btn:hover{background:rgba(255,255,255,.1)}
|
| 116 |
+
.cfg-btn.primary{background:linear-gradient(135deg,#8b5cf6,#22d3ee);border-color:transparent;color:#0a0713}
|
| 117 |
+
.cfg-btn.primary:hover{filter:brightness(1.08)}
|
| 118 |
+
@media(max-width:900px){.mode-pill{display:none}}
|
| 119 |
+
|
| 120 |
+
/* install banner (PWA) */
|
| 121 |
+
.install-bar{position:fixed;left:12px;right:12px;bottom:calc(var(--player-h) + 14px);z-index:120;
|
| 122 |
+
display:flex;align-items:center;gap:11px;padding:11px 12px;border-radius:15px;
|
| 123 |
+
background:linear-gradient(180deg,rgba(32,36,52,.99),rgba(20,23,34,.99));
|
| 124 |
+
border:1px solid rgba(139,92,246,.4);box-shadow:0 18px 50px rgba(0,0,0,.55);
|
| 125 |
+
animation:pop .26s ease;max-width:min(560px,calc(100vw - 24px));margin:0 auto}
|
| 126 |
+
.install-bar img{border-radius:9px;flex:0 0 auto}
|
| 127 |
+
.install-bar .ib-text{min-width:0;flex:1}
|
| 128 |
+
.install-bar b{display:block;font-size:13px}
|
| 129 |
+
.install-bar span{display:block;font-size:11.5px;color:var(--dim);line-height:1.4;margin-top:1px}
|
| 130 |
+
.install-bar .ib-go{flex:0 0 auto;height:34px;padding:0 14px;border-radius:9px;border:0;cursor:pointer;
|
| 131 |
+
background:linear-gradient(135deg,#8b5cf6,#22d3ee);color:#0a0713;font:inherit;font-size:12.5px;font-weight:700}
|
| 132 |
+
.install-bar .ib-x{flex:0 0 auto;width:28px;height:28px;border:0;background:transparent;color:var(--dim);
|
| 133 |
+
font-size:14px;cursor:pointer;border-radius:7px}
|
| 134 |
+
.install-bar .ib-x:hover{background:rgba(255,255,255,.08);color:var(--fg)}
|
| 135 |
+
|
| 136 |
+
/* Android: respect notches/gesture bars, kill accidental zoom + text inflation */
|
| 137 |
+
@supports (padding: env(safe-area-inset-top)) {
|
| 138 |
+
.topbar{padding-top:calc(9px + env(safe-area-inset-top))}
|
| 139 |
+
.player{padding-bottom:calc(7px + env(safe-area-inset-bottom))}
|
| 140 |
+
.sidebar{padding-left:calc(5px + env(safe-area-inset-left))}
|
| 141 |
+
}
|
| 142 |
+
html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
|
| 143 |
+
button,input,select{font-family:inherit}
|
| 144 |
+
@media (display-mode:standalone){
|
| 145 |
+
body{overscroll-behavior-y:none}
|
| 146 |
+
.topbar{user-select:none}
|
| 147 |
+
}
|
| 148 |
+
"""
|
| 149 |
+
|
| 150 |
+
# ── 3. swap the external <link>/<script> tags for inline blocks ────────────
|
| 151 |
+
def _inline_style(_m):
|
| 152 |
+
return "<style>\n" + css + extra_css + "\n</style>"
|
| 153 |
+
|
| 154 |
+
def _inline_script(_m):
|
| 155 |
+
return ("<script>\n/* ── runtime: demo engine + backend connector ── */\n" + runtime +
|
| 156 |
+
"\n/* ── dashboard ── */\n" + dash + "\n</script>")
|
| 157 |
+
|
| 158 |
+
html = re.sub(r'<link rel="stylesheet" href="/static/dash\.css[^"]*">', _inline_style, html)
|
| 159 |
+
html = re.sub(r'<script src="/static/dash\.js[^"]*"></script>', _inline_script, html)
|
| 160 |
+
|
| 161 |
+
# title + a note that it is self-contained
|
| 162 |
+
html = html.replace("<title>", "<title>", 1)
|
| 163 |
+
if '<script src="/static/' in html or '<link rel="stylesheet" href="/static/' in html:
|
| 164 |
+
sys.exit("✗ external asset references remain — the file would not be standalone")
|
| 165 |
+
|
| 166 |
+
OUT.write_text(html)
|
| 167 |
+
kb = OUT.stat().st_size / 1024
|
| 168 |
+
print(f"✓ {OUT} ({kb:.0f} KB, single file, zero external requests)")
|
| 169 |
+
|
| 170 |
+
# ── 4. the installable PWA build (Android home-screen app) ─────────────────
|
| 171 |
+
import shutil
|
| 172 |
+
|
| 173 |
+
PWA = ROOT.parent / "cadence-android"
|
| 174 |
+
if PWA.exists():
|
| 175 |
+
shutil.rmtree(PWA)
|
| 176 |
+
(PWA / "icons").mkdir(parents=True)
|
| 177 |
+
|
| 178 |
+
head_extra = """<link rel="manifest" href="manifest.webmanifest">
|
| 179 |
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
| 180 |
+
<meta name="theme-color" content="#08080d">
|
| 181 |
+
<meta name="mobile-web-app-capable" content="yes">
|
| 182 |
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
| 183 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
| 184 |
+
<meta name="apple-mobile-web-app-title" content="Cadence">
|
| 185 |
+
<meta name="application-name" content="Cadence">
|
| 186 |
+
<link rel="icon" href="icons/favicon-64.png" sizes="64x64">
|
| 187 |
+
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">"""
|
| 188 |
+
|
| 189 |
+
pwa_html = html.replace(
|
| 190 |
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
| 191 |
+
head_extra, 1)
|
| 192 |
+
pwa_html = pwa_html.replace(
|
| 193 |
+
"</body>",
|
| 194 |
+
"""<script>
|
| 195 |
+
if ('serviceWorker' in navigator && location.protocol.startsWith('http')) {
|
| 196 |
+
window.addEventListener('load', () => {
|
| 197 |
+
navigator.serviceWorker.register('sw.js').catch(err => console.warn('SW failed:', err.message));
|
| 198 |
+
});
|
| 199 |
+
}
|
| 200 |
+
</script>
|
| 201 |
+
</body>""", 1)
|
| 202 |
+
|
| 203 |
+
(PWA / "index.html").write_text(pwa_html)
|
| 204 |
+
import json as _json
|
| 205 |
+
_served = _json.loads((ROOT / "static" / "pwa" / "manifest.webmanifest").read_text())
|
| 206 |
+
_static = dict(_served)
|
| 207 |
+
_static["start_url"] = "./index.html"
|
| 208 |
+
_static["scope"] = "./"
|
| 209 |
+
_static.pop("id", None)
|
| 210 |
+
for _sc in _static.get("shortcuts", []):
|
| 211 |
+
_sc["url"] = _sc["url"].replace("/#/", "./index.html#/")
|
| 212 |
+
(PWA / "manifest.webmanifest").write_text(_json.dumps(_static, indent=2) + "\n")
|
| 213 |
+
shutil.copy(HERE / "pwa" / "sw.js", PWA / "sw.js")
|
| 214 |
+
for icon in (HERE / "icons").glob("*.png"):
|
| 215 |
+
shutil.copy(icon, PWA / "icons" / icon.name)
|
| 216 |
+
|
| 217 |
+
files = sorted(p.relative_to(PWA).as_posix() for p in PWA.rglob("*") if p.is_file())
|
| 218 |
+
total = sum((PWA / f).stat().st_size for f in files) / 1024
|
| 219 |
+
print(f"✓ {PWA} ({len(files)} files, {total:.0f} KB)")
|
standalone/icons/apple-touch-icon.png
ADDED
|
|
standalone/icons/favicon-64.png
ADDED
|
|
standalone/icons/icon-1024.png
ADDED
|
|
standalone/icons/icon-128.png
ADDED
|
|
standalone/icons/icon-180.png
ADDED
|
|
standalone/icons/icon-192.png
ADDED
|
|
standalone/icons/icon-512.png
ADDED
|
|
standalone/icons/icon-96.png
ADDED
|
|
standalone/icons/icon-maskable-512.png
ADDED
|
|
standalone/make_icons.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Generate the Android/PWA icon set for Cadence.
|
| 3 |
+
|
| 4 |
+
Brand: three rising waveform bars on a violet→cyan gradient, dark rounded tile.
|
| 5 |
+
Maskable variants keep everything inside the 80% safe zone Android crops to.
|
| 6 |
+
"""
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from PIL import Image, ImageDraw
|
| 9 |
+
|
| 10 |
+
OUT = Path(__file__).resolve().parent / "icons"
|
| 11 |
+
OUT.mkdir(exist_ok=True)
|
| 12 |
+
|
| 13 |
+
S = 1024
|
| 14 |
+
VIOLET = (139, 92, 246)
|
| 15 |
+
CYAN = (34, 211, 238)
|
| 16 |
+
BG = (10, 10, 18)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def gradient(size, c1, c2):
|
| 20 |
+
img = Image.new("RGB", (size, size))
|
| 21 |
+
d = ImageDraw.Draw(img)
|
| 22 |
+
for i in range(size):
|
| 23 |
+
t = i / max(1, size - 1)
|
| 24 |
+
d.line([(0, i), (size, i)], fill=tuple(int(c1[k] + (c2[k] - c1[k]) * t) for k in range(3)))
|
| 25 |
+
return img
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def bars(draw, size, scale=1.0, color=(255, 255, 255, 240)):
|
| 29 |
+
"""Three rounded bars of increasing height, centred."""
|
| 30 |
+
cx, cy = size / 2, size / 2
|
| 31 |
+
bar_w = size * 0.088 * scale
|
| 32 |
+
gap = size * 0.055 * scale
|
| 33 |
+
heights = [0.34, 0.52, 0.42]
|
| 34 |
+
total = 3 * bar_w + 2 * gap
|
| 35 |
+
x = cx - total / 2
|
| 36 |
+
for h in heights:
|
| 37 |
+
bh = size * h * scale
|
| 38 |
+
r = bar_w / 2
|
| 39 |
+
draw.rounded_rectangle([x, cy - bh / 2, x + bar_w, cy + bh / 2], radius=r, fill=color)
|
| 40 |
+
x += bar_w + gap
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def tile(size, maskable=False):
|
| 44 |
+
pad = int(size * 0.10) if maskable else 0
|
| 45 |
+
inner = size - 2 * pad
|
| 46 |
+
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
| 47 |
+
if maskable:
|
| 48 |
+
img.paste(Image.new("RGBA", (size, size), BG + (255,)), (0, 0))
|
| 49 |
+
|
| 50 |
+
grad = gradient(inner, VIOLET, CYAN).convert("RGBA")
|
| 51 |
+
# darken the gradient a little so white bars read clearly
|
| 52 |
+
overlay = Image.new("RGBA", (inner, inner), (8, 8, 14, 120))
|
| 53 |
+
grad = Image.alpha_composite(grad, overlay)
|
| 54 |
+
|
| 55 |
+
if maskable:
|
| 56 |
+
img.paste(grad, (pad, pad))
|
| 57 |
+
else:
|
| 58 |
+
mask = Image.new("L", (inner, inner), 0)
|
| 59 |
+
ImageDraw.Draw(mask).rounded_rectangle([0, 0, inner - 1, inner - 1], radius=int(inner * 0.22), fill=255)
|
| 60 |
+
img.paste(grad, (0, 0), mask)
|
| 61 |
+
|
| 62 |
+
d = ImageDraw.Draw(img)
|
| 63 |
+
bars(d, inner, scale=0.92, color=(255, 255, 255, 245)) if not maskable else None
|
| 64 |
+
if maskable:
|
| 65 |
+
layer = Image.new("RGBA", (inner, inner), (0, 0, 0, 0))
|
| 66 |
+
bars(ImageDraw.Draw(layer), inner, scale=0.78, color=(255, 255, 255, 245))
|
| 67 |
+
img.paste(layer, (pad, pad), layer)
|
| 68 |
+
return img
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def save(img, name):
|
| 72 |
+
img.save(OUT / name, optimize=True)
|
| 73 |
+
print(f" {name:32s} {img.size[0]}×{img.size[1]}")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
print("icons →")
|
| 77 |
+
save(tile(S), "icon-1024.png")
|
| 78 |
+
for s in (512, 192, 180, 128, 96):
|
| 79 |
+
save(tile(S).resize((s, s), Image.LANCZOS), f"icon-{s}.png")
|
| 80 |
+
save(tile(S, maskable=True).resize((512, 512), Image.LANCZOS), "icon-maskable-512.png")
|
| 81 |
+
save(tile(S).resize((180, 180), Image.LANCZOS), "apple-touch-icon.png")
|
| 82 |
+
# favicon
|
| 83 |
+
save(tile(S).resize((64, 64), Image.LANCZOS), "favicon-64.png")
|
| 84 |
+
print("done")
|
standalone/pwa/manifest.webmanifest
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Cadence — music dashboard",
|
| 3 |
+
"short_name": "Cadence",
|
| 4 |
+
"description": "Search YouTube Music, build radios from any song, and keep the queue growing. Dashboard with live playback telemetry.",
|
| 5 |
+
"start_url": "./index.html",
|
| 6 |
+
"scope": "./",
|
| 7 |
+
"display": "standalone",
|
| 8 |
+
"display_override": ["standalone", "fullscreen", "minimal-ui"],
|
| 9 |
+
"orientation": "portrait-primary",
|
| 10 |
+
"background_color": "#08080d",
|
| 11 |
+
"theme_color": "#08080d",
|
| 12 |
+
"categories": ["music", "entertainment"],
|
| 13 |
+
"lang": "en",
|
| 14 |
+
"dir": "ltr",
|
| 15 |
+
"icons": [
|
| 16 |
+
{ "src": "icons/icon-96.png", "sizes": "96x96", "type": "image/png", "purpose": "any" },
|
| 17 |
+
{ "src": "icons/icon-128.png", "sizes": "128x128", "type": "image/png", "purpose": "any" },
|
| 18 |
+
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
| 19 |
+
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
| 20 |
+
{ "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
| 21 |
+
],
|
| 22 |
+
"shortcuts": [
|
| 23 |
+
{ "name": "Search", "short_name": "Search", "url": "./index.html#/search",
|
| 24 |
+
"icons": [{ "src": "icons/icon-96.png", "sizes": "96x96" }] },
|
| 25 |
+
{ "name": "Trending", "short_name": "Trending", "url": "./index.html#/trending",
|
| 26 |
+
"icons": [{ "src": "icons/icon-96.png", "sizes": "96x96" }] },
|
| 27 |
+
{ "name": "Genres & moods", "short_name": "Genres", "url": "./index.html#/browse",
|
| 28 |
+
"icons": [{ "src": "icons/icon-96.png", "sizes": "96x96" }] }
|
| 29 |
+
]
|
| 30 |
+
}
|
standalone/pwa/sw.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Cadence service worker — makes the app installable and usable offline.
|
| 2 |
+
|
| 3 |
+
Works for both deployments:
|
| 4 |
+
• served by the Flask backend at / (icons under /static/pwa/icons/)
|
| 5 |
+
• served as static files at ./index.html (icons under ./icons/)
|
| 6 |
+
Instead of hard-coding asset paths, it reads them from the manifest, and it caches
|
| 7 |
+
each entry independently so one missing file can never break the installation.
|
| 8 |
+
*/
|
| 9 |
+
const VERSION = 'cadence-v3';
|
| 10 |
+
/* One canonical key for the app shell. We must NOT serve a redirected response for a
|
| 11 |
+
navigation (browsers reject that), and on the Flask deployment /index.html redirects
|
| 12 |
+
to / — so the shell is fetched once and stored under this synthetic URL. */
|
| 13 |
+
const SHELL = new URL('__shell', self.location.href).href;
|
| 14 |
+
const CORE = ['./', './manifest.webmanifest'];
|
| 15 |
+
|
| 16 |
+
async function cacheShell() {
|
| 17 |
+
const cache = await caches.open(VERSION);
|
| 18 |
+
|
| 19 |
+
// 1. the app itself, stored under a stable key (no redirects involved)
|
| 20 |
+
try {
|
| 21 |
+
const res = await fetch('./', { cache: 'reload' });
|
| 22 |
+
if (res.ok) await cache.put(SHELL, res);
|
| 23 |
+
} catch { /* offline at install time — a later navigation will fill it */ }
|
| 24 |
+
|
| 25 |
+
// 2. supporting files, best effort
|
| 26 |
+
await Promise.allSettled(CORE.map(url => cache.add(new Request(url, { cache: 'reload' }))));
|
| 27 |
+
|
| 28 |
+
// 2. icons come from the manifest, so the same worker serves every hosting model
|
| 29 |
+
try {
|
| 30 |
+
const res = await fetch('./manifest.webmanifest', { cache: 'reload' });
|
| 31 |
+
const manifest = await res.json();
|
| 32 |
+
const icons = [
|
| 33 |
+
...(manifest.icons || []).map(i => i.src),
|
| 34 |
+
...(manifest.shortcuts || []).flatMap(s => (s.icons || []).map(i => i.src)),
|
| 35 |
+
'icons/apple-touch-icon.png',
|
| 36 |
+
].filter(Boolean);
|
| 37 |
+
await Promise.allSettled([...new Set(icons)].map(src => cache.add(new Request(src, { cache: 'reload' }))));
|
| 38 |
+
} catch {
|
| 39 |
+
/* icons are cosmetic — offline still works without them */
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
self.addEventListener('install', event => {
|
| 44 |
+
event.waitUntil(cacheShell().then(() => self.skipWaiting()).catch(() => self.skipWaiting()));
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
self.addEventListener('activate', event => {
|
| 48 |
+
event.waitUntil(
|
| 49 |
+
caches.keys()
|
| 50 |
+
.then(keys => Promise.all(keys.filter(k => k !== VERSION).map(k => caches.delete(k))))
|
| 51 |
+
.then(() => self.clients.claim())
|
| 52 |
+
);
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
self.addEventListener('fetch', event => {
|
| 56 |
+
const req = event.request;
|
| 57 |
+
if (req.method !== 'GET') return;
|
| 58 |
+
|
| 59 |
+
const url = new URL(req.url);
|
| 60 |
+
|
| 61 |
+
// never intercept the backend API, audio streaming, or other origins
|
| 62 |
+
if (url.origin !== self.location.origin) return;
|
| 63 |
+
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/sw.js')) return;
|
| 64 |
+
|
| 65 |
+
// pages: network first (so updates land), cache as the offline fallback
|
| 66 |
+
if (req.mode === 'navigate') {
|
| 67 |
+
event.respondWith(
|
| 68 |
+
fetch(req)
|
| 69 |
+
.then(res => {
|
| 70 |
+
if (res && res.ok) {
|
| 71 |
+
const copy = res.clone();
|
| 72 |
+
caches.open(VERSION).then(c => c.put(SHELL, copy)).catch(() => {});
|
| 73 |
+
}
|
| 74 |
+
return res;
|
| 75 |
+
})
|
| 76 |
+
.catch(async () => (await caches.match(SHELL)) || (await caches.match('./')) || Response.error())
|
| 77 |
+
);
|
| 78 |
+
return;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// assets: cache first, refresh in the background
|
| 82 |
+
event.respondWith(
|
| 83 |
+
caches.match(req).then(hit => {
|
| 84 |
+
if (hit) {
|
| 85 |
+
fetch(req).then(res => {
|
| 86 |
+
if (res && res.ok) caches.open(VERSION).then(c => c.put(req, res.clone())).catch(() => {});
|
| 87 |
+
}).catch(() => {});
|
| 88 |
+
return hit;
|
| 89 |
+
}
|
| 90 |
+
return fetch(req)
|
| 91 |
+
.then(res => {
|
| 92 |
+
if (res && res.ok && url.origin === self.location.origin) {
|
| 93 |
+
const copy = res.clone();
|
| 94 |
+
caches.open(VERSION).then(c => c.put(req, copy)).catch(() => {});
|
| 95 |
+
}
|
| 96 |
+
return res;
|
| 97 |
+
})
|
| 98 |
+
.catch(async () => (await caches.match(SHELL)) || (await caches.match('./')) || Response.error());
|
| 99 |
+
})
|
| 100 |
+
);
|
| 101 |
+
});
|
standalone/runtime.js
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Cadence standalone runtime — makes dashboard.js work with no backend at all.
|
| 2 |
+
Two sources:
|
| 3 |
+
• demo → a built-in catalog + a virtual player, so every panel works offline
|
| 4 |
+
• server → your own Cadence/Flask backend (any URL), for real search & audio
|
| 5 |
+
Injected by build_standalone.py; the dashboard logic itself is unchanged. */
|
| 6 |
+
(() => {
|
| 7 |
+
'use strict';
|
| 8 |
+
|
| 9 |
+
/* Some browsers deny localStorage on file:// (opaque origin) and throw on mere access,
|
| 10 |
+
which would take the whole app down. Fall back to memory so the file always runs. */
|
| 11 |
+
(function ensureStorage() {
|
| 12 |
+
let ok = true;
|
| 13 |
+
try { window.localStorage.getItem('__probe__'); } catch { ok = false; }
|
| 14 |
+
if (ok) return;
|
| 15 |
+
const mem = new Map();
|
| 16 |
+
const shim = {
|
| 17 |
+
getItem: k => (mem.has(String(k)) ? mem.get(String(k)) : null),
|
| 18 |
+
setItem: (k, v) => { mem.set(String(k), String(v)); },
|
| 19 |
+
removeItem: k => { mem.delete(String(k)); },
|
| 20 |
+
clear: () => mem.clear(),
|
| 21 |
+
key: i => [...mem.keys()][i] ?? null,
|
| 22 |
+
get length() { return mem.size; },
|
| 23 |
+
};
|
| 24 |
+
try { Object.defineProperty(window, 'localStorage', { value: shim, configurable: true }); }
|
| 25 |
+
catch { try { window.localStorage = shim; } catch {} }
|
| 26 |
+
})();
|
| 27 |
+
|
| 28 |
+
const CFG_KEY = 'cadence:cfg';
|
| 29 |
+
let CFG = { mode: 'demo', base: 'http://localhost:8000' };
|
| 30 |
+
try { Object.assign(CFG, JSON.parse(localStorage.getItem(CFG_KEY) || '{}')); } catch {}
|
| 31 |
+
const isDemo = () => CFG.mode !== 'server';
|
| 32 |
+
|
| 33 |
+
/* ───────────────────────── deterministic helpers ───────────────────────── */
|
| 34 |
+
const hash = s => { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; };
|
| 35 |
+
const pick = (arr, seed) => arr[hash(String(seed)) % arr.length];
|
| 36 |
+
const range = (n, seed) => Array.from({ length: n }, (_, i) => i + (hash(String(seed)) % 7));
|
| 37 |
+
|
| 38 |
+
/* artwork: inline SVG gradient → works offline, from file://, no CORS */
|
| 39 |
+
const artCache = new Map();
|
| 40 |
+
function art(seed, label) {
|
| 41 |
+
if (artCache.has(seed)) return artCache.get(seed);
|
| 42 |
+
const h = hash(seed);
|
| 43 |
+
const h1 = h % 360, h2 = (h1 + 40 + (h >> 8) % 90) % 360;
|
| 44 |
+
const initials = (label || seed).split(/\s+/).slice(0, 2).map(w => w[0] || '').join('').toUpperCase();
|
| 45 |
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 300 300">
|
| 46 |
+
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
| 47 |
+
<stop offset="0" stop-color="hsl(${h1} 78% 58%)"/><stop offset="1" stop-color="hsl(${h2} 72% 34%)"/>
|
| 48 |
+
</linearGradient><radialGradient id="r" cx="0.25" cy="0.2" r="0.9">
|
| 49 |
+
<stop offset="0" stop-color="rgba(255,255,255,.5)"/><stop offset="1" stop-color="rgba(255,255,255,0)"/>
|
| 50 |
+
</radialGradient></defs>
|
| 51 |
+
<rect width="300" height="300" fill="url(#g)"/><rect width="300" height="300" fill="url(#r)"/>
|
| 52 |
+
<circle cx="${60 + h % 180}" cy="${70 + (h >> 5) % 160}" r="${40 + (h >> 9) % 55}" fill="rgba(255,255,255,.14)"/>
|
| 53 |
+
<text x="150" y="168" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="86"
|
| 54 |
+
font-weight="700" fill="rgba(255,255,255,.92)" text-anchor="middle">${initials}</text></svg>`;
|
| 55 |
+
const uri = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
| 56 |
+
artCache.set(seed, uri);
|
| 57 |
+
return uri;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/* ───────────────────────── the demo catalog ───────────────────────────── */
|
| 61 |
+
const ARTISTS = [
|
| 62 |
+
{ name: 'Neon Vesper', genre: 'synthwave', blurb: 'Analog synths, midnight drives.', since: 2014 },
|
| 63 |
+
{ name: 'Marta Okonkwo', genre: 'neo-soul', blurb: 'Velvet vocals over warm Rhodes chords.', since: 2011 },
|
| 64 |
+
{ name: 'Glass Harbor', genre: 'indie rock', blurb: 'Reverb-soaked guitars from the coast.', since: 2016 },
|
| 65 |
+
{ name: 'Kaito Mori', genre: 'lofi', blurb: 'Tape hiss and rainy-window pianos.', since: 2018 },
|
| 66 |
+
{ name: 'The Paper Lanterns', genre: 'folk', blurb: 'Harmonies recorded in one room.', since: 2009 },
|
| 67 |
+
{ name: 'Solene', genre: 'dream pop', blurb: 'Wide-screen textures, whispered hooks.', since: 2017 },
|
| 68 |
+
{ name: 'Ravi Chandran Trio', genre: 'jazz', blurb: 'Modal jazz with modern edges.', since: 2005 },
|
| 69 |
+
{ name: 'Basement Choir', genre: 'post-punk', blurb: 'Angular riffs, chorused bass.', since: 2013 },
|
| 70 |
+
{ name: 'Aiko & the Static', genre: 'electropop', blurb: 'Glitched beats and bright toplines.', since: 2019 },
|
| 71 |
+
{ name: 'Elian Cruz', genre: 'afrobeat', blurb: 'Polyrhythms built for open air.', since: 2012 },
|
| 72 |
+
{ name: 'Mira Halloran', genre: 'piano', blurb: 'Solo piano, patient and cinematic.', since: 2008 },
|
| 73 |
+
{ name: 'Static Bloom', genre: 'shoegaze', blurb: 'Wall-of-sound guitars, soft focus.', since: 2015 },
|
| 74 |
+
];
|
| 75 |
+
const GENRE_TITLES = ['synthwave', 'neo-soul', 'indie rock', 'lofi', 'folk', 'dream pop', 'jazz', 'post-punk', 'electropop', 'afrobeat', 'piano', 'shoegaze'];
|
| 76 |
+
const TITLES = {
|
| 77 |
+
'Neon Vesper': ['Midnight Arcade', 'Chrome Horizon', 'Laser Bloom', 'Afterglow Drive'],
|
| 78 |
+
'Marta Okonkwo': ['Honey Static', 'Slow Bloom', 'Kitchen Table', 'Velvet Hours'],
|
| 79 |
+
'Glass Harbor': ['Saltwater Radio', 'Ferry Lights', 'Low Tide Riot', 'Coastline Static'],
|
| 80 |
+
'Kaito Mori': ['Rain on the Balcony', 'Paper Cups', 'Second Coffee', 'Window Seat'],
|
| 81 |
+
'The Paper Lanterns': ['Winter Orchard', 'Hollow Pines', 'Long Way Home', 'Sunday Letters'],
|
| 82 |
+
'Solene': ['Glasshouse', 'Half Light', 'Soft Machine', 'Marble Sky'],
|
| 83 |
+
'Ravi Chandran Trio': ['Blue Interval', 'Snake Charmer', 'Modal Drift', 'Late Set'],
|
| 84 |
+
'Basement Choir': ['Concrete Hymn', 'Sirens', 'Blackout Kids', 'Tape Loop'],
|
| 85 |
+
'Aiko & the Static': ['Pixel Heart', 'Overdrive', 'Glitch Bloom', 'Night Bus'],
|
| 86 |
+
'Elian Cruz': ['Open Air', 'Marimba Sunrise', 'Coast Road', 'Dance Until Dawn'],
|
| 87 |
+
'Mira Halloran': ['Folded Light', 'Nocturne for Rain', 'Six Winters', 'Quiet Room'],
|
| 88 |
+
'Static Bloom': ['Gauze', 'Slow Collapse', 'Hyacinth', 'Feedback Bloom'],
|
| 89 |
+
};
|
| 90 |
+
const ALBUMS = {
|
| 91 |
+
'Neon Vesper': ['Chrome Horizon', 'Night Signals'],
|
| 92 |
+
'Marta Okonkwo': ['Velvet Hours', 'Honey Static'],
|
| 93 |
+
'Glass Harbor': ['Saltwater Radio', 'Low Tide'],
|
| 94 |
+
'Kaito Mori': ['Window Seat', 'Second Coffee'],
|
| 95 |
+
'The Paper Lanterns': ['Winter Orchard', 'Long Way Home'],
|
| 96 |
+
'Solene': ['Glasshouse', 'Half Light'],
|
| 97 |
+
'Ravi Chandran Trio': ['Late Set', 'Modal Drift'],
|
| 98 |
+
'Basement Choir': ['Concrete Hymn', 'Blackout Kids'],
|
| 99 |
+
'Aiko & the Static': ['Pixel Heart', 'Night Bus'],
|
| 100 |
+
'Elian Cruz': ['Open Air', 'Marimba Sunrise'],
|
| 101 |
+
'Mira Halloran': ['Folded Light', 'Quiet Room'],
|
| 102 |
+
'Static Bloom': ['Gauze', 'Hyacinth'],
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
const artistId = name => 'AR' + hash(name).toString(36).slice(0, 14);
|
| 106 |
+
const albumId = name => 'AL' + hash(name).toString(36).slice(0, 14);
|
| 107 |
+
const ARTIST_BY_ID = new Map(ARTISTS.map(a => [artistId(a.name), a]));
|
| 108 |
+
|
| 109 |
+
const CATALOG = [];
|
| 110 |
+
ARTISTS.forEach((a, ai) => {
|
| 111 |
+
(TITLES[a.name] || []).forEach((title, si) => {
|
| 112 |
+
const alb = ALBUMS[a.name][si % ALBUMS[a.name].length];
|
| 113 |
+
const vid = 'demo' + hash(a.name + title).toString(36).slice(0, 9);
|
| 114 |
+
CATALOG.push({
|
| 115 |
+
video_id: vid,
|
| 116 |
+
title,
|
| 117 |
+
artists: [{ name: a.name, id: artistId(a.name) }],
|
| 118 |
+
artist_text: a.name,
|
| 119 |
+
album: { name: alb, id: albumId(alb) },
|
| 120 |
+
duration: 168 + (hash(vid) % 130),
|
| 121 |
+
year: a.since + (si % 9),
|
| 122 |
+
_genre: a.genre,
|
| 123 |
+
});
|
| 124 |
+
});
|
| 125 |
+
});
|
| 126 |
+
const CAT_BY_ID = new Map(CATALOG.map(t => [t.video_id, t]));
|
| 127 |
+
const artistObj = name => {
|
| 128 |
+
const a = ARTISTS.find(x => x.name === name) || ARTISTS[0];
|
| 129 |
+
return { id: artistId(a.name), name: a.name, thumbnail: 'artist:' + a.name,
|
| 130 |
+
subscribers: ((hash(a.name) % 900) / 100 + 0.4).toFixed(1) + 'M',
|
| 131 |
+
monthly_listeners: 200000 + (hash(a.name) % 8000000) };
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
const REGIONS = ['ZZ', 'CH', 'US', 'GB', 'DE', 'FR', 'IT', 'ES', 'AT', 'NL', 'SE', 'NO', 'DK', 'FI', 'BE', 'PT', 'PL', 'CZ', 'IE', 'GR', 'JP', 'KR', 'IN', 'BR', 'MX', 'AR', 'CA', 'AU', 'NZ', 'ZA', 'NG', 'TR'];
|
| 135 |
+
const REGION_NAMES = { ZZ: 'Global', CH: 'Switzerland', US: 'United States', GB: 'United Kingdom', DE: 'Germany', FR: 'France', IT: 'Italy', ES: 'Spain', AT: 'Austria', NL: 'Netherlands', SE: 'Sweden', NO: 'Norway', DK: 'Denmark', FI: 'Finland', BE: 'Belgium', PT: 'Portugal', PL: 'Poland', CZ: 'Czechia', IE: 'Ireland', GR: 'Greece', JP: 'Japan', KR: 'South Korea', IN: 'India', BR: 'Brazil', MX: 'Mexico', AR: 'Argentina', CA: 'Canada', AU: 'Australia', NZ: 'New Zealand', ZA: 'South Africa', NG: 'Nigeria', TR: 'Türkiye' };
|
| 136 |
+
|
| 137 |
+
const MOODS = ['Chill', 'Commute', 'Energize', 'Feel good', 'Focus', 'Romance', 'Sad hours', 'Sleep', 'Workout', 'Dinner party', 'Sunday morning'];
|
| 138 |
+
const GENRES = ['Pop', 'Rock', 'Hip-Hop', 'Electronic', 'Jazz', 'Lo-fi', 'Indie', 'Metal', 'Classical', 'Soul', 'Funk', 'Folk', 'Latin', 'Ambient'];
|
| 139 |
+
|
| 140 |
+
/* shuffle that is stable per seed so a page re-render doesn't reshuffle */
|
| 141 |
+
const stableShuffle = (arr, seed) => arr.map(t => [hash(seed + t.video_id), t]).sort((a, b) => a[0] - b[0]).map(x => x[1]);
|
| 142 |
+
const strip = t => ({ video_id: t.video_id, title: t.title, artists: t.artists, artist_text: t.artist_text, album: t.album, duration: t.duration, year: t.year });
|
| 143 |
+
|
| 144 |
+
const playlistsFor = (key, seed) => {
|
| 145 |
+
const names = [`${key} Essentials`, `${key} Deep Cuts`, `Late Night ${key}`, `${key} Rising`, `Pure ${key}`, `${key} for the Ride`, `${key} Classics`, `Fresh ${key}`];
|
| 146 |
+
return names.map((title, i) => ({
|
| 147 |
+
title, id: 'PL' + hash(key + title).toString(36).slice(0, 14),
|
| 148 |
+
thumbnail: art('pl:' + key + title, title), count: 18 + (hash(title) % 40), _key: key, _i: i,
|
| 149 |
+
}));
|
| 150 |
+
};
|
| 151 |
+
|
| 152 |
+
/* ───────────────────────── simulated engine telemetry ──────────────────── */
|
| 153 |
+
const STARTED = Date.now();
|
| 154 |
+
const STATS = { cached_bytes: 0, cached_tracks: 0, downloads: 0, download_failures: 0, secs: 0, downloads_inflight: 0 };
|
| 155 |
+
const CACHED = new Map(); // video_id → bytes, so the tile reports distinct tracks
|
| 156 |
+
const CACHE_LIMIT = 3 * 1024 * 1024 * 1024;
|
| 157 |
+
|
| 158 |
+
/* ───────────────────────── the demo "API" ──────────────────────────────── */
|
| 159 |
+
function demoApi(path) {
|
| 160 |
+
const [rawPath, qs] = path.split('?');
|
| 161 |
+
const q = new URLSearchParams(qs || '');
|
| 162 |
+
const p = rawPath.replace(/\/+$/, '');
|
| 163 |
+
|
| 164 |
+
if (p === '/api/stats') {
|
| 165 |
+
return { cached_tracks: STATS.cached_tracks, cached_bytes: STATS.cached_bytes, downloads: STATS.downloads,
|
| 166 |
+
download_failures: STATS.download_failures, avg_download_seconds: STATS.downloads ? +(STATS.secs / STATS.downloads).toFixed(2) : null,
|
| 167 |
+
downloads_inflight: STATS.downloads_inflight, bytes_served: STATS.cached_bytes,
|
| 168 |
+
uptime_seconds: Math.round((Date.now() - STARTED) / 1000), cache_limit_bytes: CACHE_LIMIT };
|
| 169 |
+
}
|
| 170 |
+
if (p.startsWith('/api/ready/')) return { cached: true, bytes: 3_400_000 + (hash(p) % 900_000) };
|
| 171 |
+
|
| 172 |
+
if (p === '/api/genres') {
|
| 173 |
+
return { categories: [
|
| 174 |
+
{ title: 'Moods & moments', items: MOODS.map(m => ({ title: m, params: 'mood:' + m })) },
|
| 175 |
+
{ title: 'Genres', items: GENRES.map(g => ({ title: g, params: 'genre:' + g })) },
|
| 176 |
+
] };
|
| 177 |
+
}
|
| 178 |
+
if (p === '/api/genre') {
|
| 179 |
+
const params = q.get('params') || '';
|
| 180 |
+
const key = params.split(':').slice(1).join(':') || 'Chill';
|
| 181 |
+
return { playlists: playlistsFor(key, params) };
|
| 182 |
+
}
|
| 183 |
+
if (p === '/api/playlist') {
|
| 184 |
+
const id = q.get('id') || '';
|
| 185 |
+
const limit = Math.min(Number(q.get('limit') || 80) || 80, 150);
|
| 186 |
+
const pl = playlistsFor('Chill', 'x').find(x => x.id === id);
|
| 187 |
+
const key = id.slice(2, 6) || 'chill';
|
| 188 |
+
const tracks = stableShuffle(CATALOG, key).slice(0, limit).map(strip);
|
| 189 |
+
return { title: pl ? pl.title : 'Playlist', author: 'Cadence demo library', track_count: tracks.length, tracks };
|
| 190 |
+
}
|
| 191 |
+
if (p === '/api/album') {
|
| 192 |
+
const id = q.get('id') || '';
|
| 193 |
+
const name = ARTISTS.map(a => ALBUMS[a.name]).flat().find(al => albumId(al) === id) || 'Chrome Horizon';
|
| 194 |
+
const tracks = CATALOG.filter(t => t.album && t.album.name === name).map(strip);
|
| 195 |
+
const year = tracks.length ? tracks[0].year : 2024;
|
| 196 |
+
return { id, title: name, year, artist: tracks.length ? tracks[0].artist_text : '', tracks };
|
| 197 |
+
}
|
| 198 |
+
if (p === '/api/artist') {
|
| 199 |
+
const name = q.get('name') || (ARTIST_BY_ID.get(q.get('id') || '') || {}).name || 'Neon Vesper';
|
| 200 |
+
const a = ARTISTS.find(x => x.name === name) || ARTISTS[0];
|
| 201 |
+
const top = CATALOG.filter(t => t.artist_text === a.name).map(strip);
|
| 202 |
+
const similar = ARTISTS.filter(x => x.genre !== a.genre).slice(0, 6)
|
| 203 |
+
.map(x => { const o = artistObj(x.name); return { id: o.id, name: o.name, thumbnail: o.thumbnail }; });
|
| 204 |
+
const albums = ALBUMS[a.name].map(al => ({ id: albumId(al), title: al, type: 'Album',
|
| 205 |
+
year: (CATALOG.find(t => t.album.name === al) || {}).year }));
|
| 206 |
+
return { ...artistObj(a.name), description: a.blurb + ' Active since ' + a.since + '.',
|
| 207 |
+
top_tracks: top, similar, albums };
|
| 208 |
+
}
|
| 209 |
+
if (p === '/api/artist/radio') {
|
| 210 |
+
const name = (ARTIST_BY_ID.get(q.get('id') || '') || {}).name || 'Neon Vesper';
|
| 211 |
+
const own = CATALOG.filter(t => t.artist_text === name);
|
| 212 |
+
const others = stableShuffle(CATALOG.filter(t => t.artist_text !== name), name).slice(0, 24);
|
| 213 |
+
return { station_name: name + ' Radio', tracks: [...own, ...others].map(strip) };
|
| 214 |
+
}
|
| 215 |
+
if (p === '/api/radio') {
|
| 216 |
+
const seedId = q.get('videoId') || '';
|
| 217 |
+
const seed = CAT_BY_ID.get(seedId) || CATALOG[0];
|
| 218 |
+
const same = CATALOG.filter(t => t.artist_text === seed.artist_text && t.video_id !== seed.video_id);
|
| 219 |
+
const vibe = stableShuffle(CATALOG.filter(t => t._genre === seed._genre && t.artist_text !== seed.artist_text), seedId);
|
| 220 |
+
const rest = stableShuffle(CATALOG.filter(t => t._genre !== seed._genre), seedId);
|
| 221 |
+
const tracks = [...same, ...vibe, ...rest].slice(0, 25).map(strip);
|
| 222 |
+
return { seed: seed.title, tracks };
|
| 223 |
+
}
|
| 224 |
+
if (p === '/api/suggest') {
|
| 225 |
+
const v = (q.get('q') || '').toLowerCase();
|
| 226 |
+
const pool = [...new Set([...CATALOG.map(t => t.title), ...ARTISTS.map(a => a.name)])];
|
| 227 |
+
return { suggestions: pool.filter(s => s.toLowerCase().includes(v)).slice(0, 8) };
|
| 228 |
+
}
|
| 229 |
+
if (p === '/api/trending') {
|
| 230 |
+
const country = (q.get('country') || 'ZZ').toUpperCase();
|
| 231 |
+
const limit = Math.min(Number(q.get('limit') || 24) || 24, 50);
|
| 232 |
+
const tracks = stableShuffle(CATALOG, 'chart:' + country).slice(0, limit).map(strip);
|
| 233 |
+
const artists = stableShuffle(ARTISTS, 'artists:' + country).slice(0, 20).map((a, i) => {
|
| 234 |
+
const o = artistObj(a.name);
|
| 235 |
+
const trend = hash(a.name + country) % 3;
|
| 236 |
+
return { id: o.id, name: o.name, thumbnail: o.thumbnail, rank: i + 1, trend: trend === 0 ? 'up' : trend === 1 ? 'down' : null };
|
| 237 |
+
});
|
| 238 |
+
return { country, country_name: REGION_NAMES[country] || country, countries: REGIONS, tracks, artists };
|
| 239 |
+
}
|
| 240 |
+
if (p === '/api/search') {
|
| 241 |
+
const term = (q.get('q') || '').toLowerCase().trim();
|
| 242 |
+
const tokens = term.replace(/[-–—]/g, ' ').split(/\s+/).filter(t => t.length > 1);
|
| 243 |
+
const fieldScore = (field, weight) => tokens.reduce((n, tok) => n + (field.includes(tok) ? weight : 0), 0);
|
| 244 |
+
|
| 245 |
+
const scored = CATALOG.map(t => {
|
| 246 |
+
const title = t.title.toLowerCase();
|
| 247 |
+
const artist = t.artist_text.toLowerCase();
|
| 248 |
+
const album = ((t.album && t.album.name) || '').toLowerCase();
|
| 249 |
+
const genre = t._genre.toLowerCase();
|
| 250 |
+
let sc = fieldScore(title, 6) + fieldScore(artist, 9) + fieldScore(album, 3) + fieldScore(genre, 4);
|
| 251 |
+
if (term && artist === term) sc += 60;
|
| 252 |
+
if (term && title === term) sc += 55;
|
| 253 |
+
if (term && artist.includes(term)) sc += 25;
|
| 254 |
+
if (term && title.includes(term)) sc += 20;
|
| 255 |
+
// every word matching is much better than a partial match
|
| 256 |
+
if (tokens.length > 1 && tokens.every(tok => (title + ' ' + artist + ' ' + genre).includes(tok))) sc += 18;
|
| 257 |
+
return [sc, t];
|
| 258 |
+
}).filter(x => x[0] > 0).sort((a, b) => b[0] - a[0]).map(x => x[1]);
|
| 259 |
+
|
| 260 |
+
// artists: name/genre matches first, then whoever performs the matching songs
|
| 261 |
+
const byName = ARTISTS.filter(a => term && (a.name.toLowerCase().includes(term) ||
|
| 262 |
+
tokens.some(tok => a.name.toLowerCase().includes(tok) || a.genre.includes(tok))));
|
| 263 |
+
const bySong = scored.map(t => ARTISTS.find(a => a.name === t.artist_text)).filter(Boolean);
|
| 264 |
+
const artists = [...new Map([...byName, ...bySong].map(a => [a.name, a])).values()].slice(0, 8)
|
| 265 |
+
.map(a => { const o = artistObj(a.name); return { id: o.id, name: o.name, thumbnail: o.thumbnail }; });
|
| 266 |
+
|
| 267 |
+
// honest: no filler tracks when nothing matches
|
| 268 |
+
return {
|
| 269 |
+
tracks: scored.slice(0, 24).map(strip),
|
| 270 |
+
artists,
|
| 271 |
+
demo: true,
|
| 272 |
+
suggestions: scored.length || artists.length ? [] :
|
| 273 |
+
[...new Set([...ARTISTS.map(a => a.name), ...GENRES])].slice(0, 6),
|
| 274 |
+
};
|
| 275 |
+
}
|
| 276 |
+
return {};
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
/* ───────────────────────── virtual player (demo mode) ──────────────────── */
|
| 280 |
+
function makeVirtualAudio() {
|
| 281 |
+
const listeners = new Map();
|
| 282 |
+
let timer = null;
|
| 283 |
+
const emit = (type, extra) => (listeners.get(type) || []).forEach(fn => fn(Object.assign({ type, target: el }, extra)));
|
| 284 |
+
const el = {
|
| 285 |
+
currentTime: 0, duration: 0, volume: 0.9, muted: false, paused: true, _src: '',
|
| 286 |
+
addEventListener(type, fn) { if (!listeners.has(type)) listeners.set(type, []); listeners.get(type).push(fn); },
|
| 287 |
+
removeEventListener() {},
|
| 288 |
+
dispatchEvent(e) { emit(e && e.type ? e.type : 'unknown'); return true; },
|
| 289 |
+
play() {
|
| 290 |
+
el.paused = false; emit('playing');
|
| 291 |
+
clearInterval(timer);
|
| 292 |
+
timer = setInterval(() => {
|
| 293 |
+
if (el.paused) return;
|
| 294 |
+
el.currentTime = Math.min(el.duration || 0, el.currentTime + 0.25);
|
| 295 |
+
emit('timeupdate');
|
| 296 |
+
if (el.duration && el.currentTime >= el.duration) { clearInterval(timer); el.paused = true; emit('ended'); }
|
| 297 |
+
}, 250);
|
| 298 |
+
return Promise.resolve();
|
| 299 |
+
},
|
| 300 |
+
pause() { el.paused = true; clearInterval(timer); emit('pause'); },
|
| 301 |
+
load() {},
|
| 302 |
+
get src() { return el._src; },
|
| 303 |
+
set src(v) {
|
| 304 |
+
el._src = v;
|
| 305 |
+
const id = String(v).split('/').pop();
|
| 306 |
+
const t = CAT_BY_ID.get(id);
|
| 307 |
+
el.duration = t ? t.duration : 0;
|
| 308 |
+
el.currentTime = 0;
|
| 309 |
+
STATS.downloads += 1; STATS.secs += 0.4 + (hash(id) % 90) / 100;
|
| 310 |
+
if (!CAT_BY_ID.has(id) || CACHED.has(id)) { STATS.downloads_inflight = 0; return; }
|
| 311 |
+
const bytes = Math.round(el.duration * 16000);
|
| 312 |
+
CACHED.set(id, bytes);
|
| 313 |
+
STATS.cached_tracks = CACHED.size;
|
| 314 |
+
STATS.cached_bytes = Math.min(CACHE_LIMIT, STATS.cached_bytes + bytes);
|
| 315 |
+
},
|
| 316 |
+
};
|
| 317 |
+
return el;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
/* ───────────────────────── source dispatcher ───────────────────────────── */
|
| 321 |
+
async function serverFetch(path, opts) {
|
| 322 |
+
let r;
|
| 323 |
+
try { r = await fetch(CFG.base.replace(/\/$/, '') + path, opts); }
|
| 324 |
+
catch (e) {
|
| 325 |
+
const err = new Error('Can\'t reach the Cadence server at ' + CFG.base + ' — check the URL in ⚙ Settings, or switch to Demo mode.');
|
| 326 |
+
err.code = 'offline'; throw err;
|
| 327 |
+
}
|
| 328 |
+
let data = null; try { data = await r.json(); } catch {}
|
| 329 |
+
if (!r.ok) {
|
| 330 |
+
const err = new Error((data && data.error) || `Request failed (${r.status})`);
|
| 331 |
+
err.status = r.status; err.code = data && data.code; err.hint = data && data.hint; throw err;
|
| 332 |
+
}
|
| 333 |
+
return data;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
const SRC = {
|
| 337 |
+
get mode() { return CFG.mode; },
|
| 338 |
+
get base() { return CFG.base; },
|
| 339 |
+
async api(path, opts) {
|
| 340 |
+
if (isDemo()) return demoApi(path);
|
| 341 |
+
return serverFetch(path, opts);
|
| 342 |
+
},
|
| 343 |
+
thumb(vid) { return isDemo() ? art('v:' + vid, (CAT_BY_ID.get(vid) || {}).title || vid) : CFG.base.replace(/\/$/, '') + '/api/thumb/' + vid; },
|
| 344 |
+
img(u) {
|
| 345 |
+
if (isDemo()) {
|
| 346 |
+
const label = String(u).startsWith('artist:') ? String(u).slice(7) : 'Artist';
|
| 347 |
+
return art('i:' + u, label);
|
| 348 |
+
}
|
| 349 |
+
return CFG.base.replace(/\/$/, '') + '/api/img?u=' + encodeURIComponent(u);
|
| 350 |
+
},
|
| 351 |
+
makeAudio(el) {
|
| 352 |
+
if (isDemo()) return makeVirtualAudio();
|
| 353 |
+
// the page may live on another origin, so /api/stream/... must be made absolute
|
| 354 |
+
const base = CFG.base.replace(/\/$/, '');
|
| 355 |
+
try {
|
| 356 |
+
const proto = Object.getOwnPropertyDescriptor(window.HTMLMediaElement.prototype, 'src');
|
| 357 |
+
Object.defineProperty(el, 'src', {
|
| 358 |
+
configurable: true,
|
| 359 |
+
get() { return proto.get.call(el); },
|
| 360 |
+
set(v) { proto.set.call(el, String(v).startsWith('/api/') ? base + v : v); },
|
| 361 |
+
});
|
| 362 |
+
} catch {}
|
| 363 |
+
return el;
|
| 364 |
+
},
|
| 365 |
+
async prefetch(id) { if (isDemo()) return; try { await fetch(CFG.base.replace(/\/$/, '') + '/api/prefetch/' + id, { method: 'POST' }); } catch {} },
|
| 366 |
+
async diagnose(id) {
|
| 367 |
+
if (isDemo()) return { kind: 'transient' };
|
| 368 |
+
try {
|
| 369 |
+
const r = await fetch(CFG.base.replace(/\/$/, '') + '/api/stream/' + id, { headers: { Range: 'bytes=0-1' } });
|
| 370 |
+
if (r.ok) return { kind: 'transient' };
|
| 371 |
+
let d = {}; try { d = await r.json(); } catch {}
|
| 372 |
+
return { kind: d.code || 'unknown', message: d.error, hint: d.hint };
|
| 373 |
+
} catch { return { kind: 'offline' }; }
|
| 374 |
+
},
|
| 375 |
+
saveCfg(next) {
|
| 376 |
+
Object.assign(CFG, next);
|
| 377 |
+
try { localStorage.setItem(CFG_KEY, JSON.stringify(CFG)); } catch {}
|
| 378 |
+
location.reload();
|
| 379 |
+
},
|
| 380 |
+
};
|
| 381 |
+
window.__SRC__ = SRC;
|
| 382 |
+
window.__DEMO_CATALOG__ = CATALOG;
|
| 383 |
+
|
| 384 |
+
/* ───────────────────────── settings dialog (injected) ──────────────────── */
|
| 385 |
+
function mountUI() {
|
| 386 |
+
const topbar = document.querySelector('.topbar');
|
| 387 |
+
if (!topbar) return;
|
| 388 |
+
const pill = document.createElement('button');
|
| 389 |
+
pill.className = 'mode-pill ' + (isDemo() ? 'demo' : 'live');
|
| 390 |
+
pill.id = 'modePill';
|
| 391 |
+
pill.innerHTML = isDemo() ? '● demo' : '● connected';
|
| 392 |
+
pill.title = isDemo() ? 'Demo data — click to connect a backend' : 'Live backend: ' + CFG.base;
|
| 393 |
+
topbar.appendChild(pill);
|
| 394 |
+
|
| 395 |
+
const wrap = document.createElement('div');
|
| 396 |
+
wrap.className = 'cfg-overlay';
|
| 397 |
+
wrap.id = 'cfgOverlay';
|
| 398 |
+
wrap.hidden = true;
|
| 399 |
+
wrap.innerHTML = `
|
| 400 |
+
<div class="cfg-card" role="dialog" aria-modal="true" aria-label="Dashboard settings">
|
| 401 |
+
<h2>Dashboard settings</h2>
|
| 402 |
+
<p class="cfg-sub">This file runs on its own. Point it at a backend for real search and playback — or keep exploring the built-in demo library.</p>
|
| 403 |
+
<label class="cfg-opt"><input type="radio" name="cfgmode" value="demo" ${isDemo() ? 'checked' : ''}>
|
| 404 |
+
<span><b>Demo mode</b><em>Built-in catalog, simulated playback. Works offline, from a file, on any static host.</em></span></label>
|
| 405 |
+
<label class="cfg-opt"><input type="radio" name="cfgmode" value="server" ${isDemo() ? '' : 'checked'}>
|
| 406 |
+
<span><b>Connect to a Cadence backend</b><em>Real YouTube Music search, artist radios and audio streaming.</em></span></label>
|
| 407 |
+
<input class="cfg-url" id="cfgUrl" type="url" placeholder="http://localhost:8000" value="${CFG.base.replace(/"/g, '"')}">
|
| 408 |
+
<div class="cfg-note" id="cfgNote">Run <code>./run.sh</code> in the Cadence project, then use <code>http://localhost:8000</code>.<br>
|
| 409 |
+
If this page is served over <b>https</b>, the backend must be https too (browsers block mixed content).</div>
|
| 410 |
+
<div class="cfg-actions">
|
| 411 |
+
<button class="cfg-btn" id="cfgTest">Test connection</button>
|
| 412 |
+
<button class="cfg-btn primary" id="cfgSave">Save & reload</button>
|
| 413 |
+
</div>
|
| 414 |
+
</div>`;
|
| 415 |
+
document.body.appendChild(wrap);
|
| 416 |
+
|
| 417 |
+
const open = () => { wrap.hidden = false; };
|
| 418 |
+
SRC.openSettings = open;
|
| 419 |
+
pill.addEventListener('click', open);
|
| 420 |
+
wrap.addEventListener('click', e => { if (e.target === wrap) wrap.hidden = true; });
|
| 421 |
+
document.addEventListener('keydown', e => { if (e.key === 'Escape') wrap.hidden = true; });
|
| 422 |
+
|
| 423 |
+
const note = wrap.querySelector('#cfgNote');
|
| 424 |
+
wrap.querySelector('#cfgTest').addEventListener('click', async () => {
|
| 425 |
+
const url = wrap.querySelector('#cfgUrl').value.trim().replace(/\/$/, '') || 'http://localhost:8000';
|
| 426 |
+
note.className = 'cfg-note';
|
| 427 |
+
note.textContent = 'Testing ' + url + ' …';
|
| 428 |
+
try {
|
| 429 |
+
const r = await fetch(url + '/api/stats', { cache: 'no-store' });
|
| 430 |
+
const d = await r.json();
|
| 431 |
+
note.className = 'cfg-note ok';
|
| 432 |
+
note.textContent = `Connected ✓ — ${d.cached_tracks} cached tracks, ${(d.cached_bytes / 1048576).toFixed(1)} MB ready.`;
|
| 433 |
+
} catch {
|
| 434 |
+
note.className = 'cfg-note err';
|
| 435 |
+
note.textContent = 'No answer from ' + url + '. Is the backend running, and does it allow cross-origin requests?';
|
| 436 |
+
}
|
| 437 |
+
});
|
| 438 |
+
wrap.querySelector('#cfgSave').addEventListener('click', () => {
|
| 439 |
+
const mode = wrap.querySelector('input[name="cfgmode"]:checked').value;
|
| 440 |
+
const base = wrap.querySelector('#cfgUrl').value.trim().replace(/\/$/, '') || 'http://localhost:8000';
|
| 441 |
+
SRC.saveCfg({ mode, base });
|
| 442 |
+
});
|
| 443 |
+
}
|
| 444 |
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountUI);
|
| 445 |
+
else mountUI();
|
| 446 |
+
|
| 447 |
+
/* ── PWA: install banner (Chrome/Edge/Samsung Internet, incl. Android) ── */
|
| 448 |
+
const isStandalone = () =>
|
| 449 |
+
window.matchMedia('(display-mode: standalone)').matches ||
|
| 450 |
+
window.matchMedia('(display-mode: fullscreen)').matches ||
|
| 451 |
+
window.navigator.standalone === true;
|
| 452 |
+
|
| 453 |
+
let deferredPrompt = null;
|
| 454 |
+
window.addEventListener('beforeinstallprompt', e => {
|
| 455 |
+
e.preventDefault();
|
| 456 |
+
deferredPrompt = e;
|
| 457 |
+
if (isStandalone() || localStorage.getItem('cadence:install-dismissed')) return;
|
| 458 |
+
showInstallBar();
|
| 459 |
+
});
|
| 460 |
+
|
| 461 |
+
window.addEventListener('appinstalled', () => {
|
| 462 |
+
deferredPrompt = null;
|
| 463 |
+
const bar = document.getElementById('installBar');
|
| 464 |
+
if (bar) bar.remove();
|
| 465 |
+
try { localStorage.setItem('cadence:install-dismissed', '1'); } catch {}
|
| 466 |
+
});
|
| 467 |
+
|
| 468 |
+
function showInstallBar() {
|
| 469 |
+
if (document.getElementById('installBar') || isStandalone()) return;
|
| 470 |
+
const bar = document.createElement('div');
|
| 471 |
+
bar.className = 'install-bar';
|
| 472 |
+
bar.id = 'installBar';
|
| 473 |
+
bar.innerHTML = `
|
| 474 |
+
<img src="icons/icon-192.png" alt="" width="40" height="40">
|
| 475 |
+
<div class="ib-text"><b>Install Cadence</b><span>Add it to your home screen — opens full screen, works offline.</span></div>
|
| 476 |
+
<button class="ib-go" id="ibInstall">Install</button>
|
| 477 |
+
<button class="ib-x" id="ibDismiss" aria-label="Not now">✕</button>`;
|
| 478 |
+
document.body.appendChild(bar);
|
| 479 |
+
bar.querySelector('#ibInstall').addEventListener('click', async () => {
|
| 480 |
+
if (!deferredPrompt) return;
|
| 481 |
+
deferredPrompt.prompt();
|
| 482 |
+
const { outcome } = await deferredPrompt.userChoice;
|
| 483 |
+
deferredPrompt = null;
|
| 484 |
+
if (outcome !== 'accepted') bar.remove();
|
| 485 |
+
});
|
| 486 |
+
bar.querySelector('#ibDismiss').addEventListener('click', () => {
|
| 487 |
+
try { localStorage.setItem('cadence:install-dismissed', '1'); } catch {}
|
| 488 |
+
bar.remove();
|
| 489 |
+
});
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
// Android Chrome fires this when the criteria are met; keep the UI in sync if it retries
|
| 493 |
+
setInterval(() => { if (deferredPrompt && !isStandalone()) showInstallBar(); }, 4000);
|
| 494 |
+
})();
|
static/app.js
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ── Cadence front-end ─────────────────────────────────────────────────── */
|
| 2 |
+
(() => {
|
| 3 |
+
'use strict';
|
| 4 |
+
|
| 5 |
+
const $ = (s, r = document) => r.querySelector(s);
|
| 6 |
+
const audio = $('#audio');
|
| 7 |
+
|
| 8 |
+
/* ── state ──────────────────────────────────────────────────────────────── */
|
| 9 |
+
const state = {
|
| 10 |
+
queue: [], // array of track objects (play order)
|
| 11 |
+
index: -1, // index of the loaded track
|
| 12 |
+
current: null,
|
| 13 |
+
viewLists: {}, // named track lists of the rendered view
|
| 14 |
+
shuffle: false,
|
| 15 |
+
repeat: 0, // 0 = off, 1 = all, 2 = one
|
| 16 |
+
growing: false,
|
| 17 |
+
history: [],
|
| 18 |
+
lastSearch: { tracks: [], artists: [] },
|
| 19 |
+
suggestItems: [],
|
| 20 |
+
suggestActive: -1,
|
| 21 |
+
token: 0, // guards against out-of-order async renders
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
const LS = {
|
| 25 |
+
get(k, d) { try { return JSON.parse(localStorage.getItem('cadence:' + k)) ?? d; } catch { return d; } },
|
| 26 |
+
set(k, v) { try { localStorage.setItem('cadence:' + k, JSON.stringify(v)); } catch {} },
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
/* ── tiny helpers ───────────────────────────────────────────────────────── */
|
| 30 |
+
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
| 31 |
+
const icon = n => `<svg aria-hidden="true"><use href="#i-${n}"/></svg>`;
|
| 32 |
+
|
| 33 |
+
function fmtTime(sec) {
|
| 34 |
+
if (!sec || !isFinite(sec) || sec < 0) return '0:00';
|
| 35 |
+
sec = Math.floor(sec);
|
| 36 |
+
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
| 37 |
+
return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function toast(msg, isErr = false, ms = 3200) {
|
| 41 |
+
const el = document.createElement('div');
|
| 42 |
+
el.className = 'toast' + (isErr ? ' err' : '');
|
| 43 |
+
el.textContent = msg;
|
| 44 |
+
$('#toasts').appendChild(el);
|
| 45 |
+
setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; setTimeout(() => el.remove(), 320); }, ms);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
async function api(path, opts) {
|
| 49 |
+
const r = await fetch(path, opts);
|
| 50 |
+
let data = null;
|
| 51 |
+
try { data = await r.json(); } catch {}
|
| 52 |
+
if (!r.ok) throw new Error((data && data.error) || `Request failed (${r.status})`);
|
| 53 |
+
return data;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const thumb = vid => `/api/thumb/${vid}`;
|
| 57 |
+
const artOf = t => t.album && t.album.id ? t.album.id : null;
|
| 58 |
+
const artistName = t => t.artist_text || 'Unknown artist';
|
| 59 |
+
|
| 60 |
+
function artistLink(a) {
|
| 61 |
+
return `<a data-artist data-artist-id="${esc(a.id || '')}" data-artist-name="${esc(a.name)}" href="#">${esc(a.name)}</a>`;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/* ── rendering primitives ───────────────────────────────────────────────── */
|
| 65 |
+
function trackRows(tracks, listName, startNum = 1, showAlbum = true) {
|
| 66 |
+
if (!tracks || !tracks.length) return `<div class="empty"><h3>No tracks</h3><p>Try another search.</p></div>`;
|
| 67 |
+
return `<div class="tracks">` + tracks.map((t, i) => `
|
| 68 |
+
<div class="trow" data-vid="${esc(t.video_id)}" data-list="${esc(listName)}" data-i="${i}">
|
| 69 |
+
<div class="idx"><span class="num" data-n="${startNum + i}">${startNum + i}</span>
|
| 70 |
+
<span class="playicon">${icon('play')}</span></div>
|
| 71 |
+
<img class="art" src="${thumb(t.video_id)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">
|
| 72 |
+
<div class="meta">
|
| 73 |
+
<div class="tt">${esc(t.title)}</div>
|
| 74 |
+
<div class="ta">${(t.artists || []).map(artistLink).join(', ') || esc(artistName(t))}</div>
|
| 75 |
+
</div>
|
| 76 |
+
<div class="album">${t.album && t.album.id
|
| 77 |
+
? `<a data-album="${esc(t.album.id)}" href="#">${esc(t.album.name || '')}</a>`
|
| 78 |
+
: esc((t.album && t.album.name) || '')}</div>
|
| 79 |
+
<div class="end">
|
| 80 |
+
<button class="icon-btn addq" data-action="similar" title="Play similar songs (radio)">${icon('spark')}</button>
|
| 81 |
+
<button class="icon-btn addq" data-action="queue" title="Add to queue">${icon('plus')}</button>
|
| 82 |
+
<span>${esc(fmtTime(t.duration))}</span>
|
| 83 |
+
</div>
|
| 84 |
+
</div>`).join('') + `</div>`;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function skeletons(n = 8) {
|
| 88 |
+
return Array.from({ length: n }, () => `<div class="skeleton"></div>`).join('');
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function card({ img, title, sub, attrs, iconName }) {
|
| 92 |
+
return `<button class="card" ${attrs || ''}>
|
| 93 |
+
<div class="card-art">${img ? `<img src="${esc(img)}" alt="" loading="lazy">` : icon(iconName || 'artist')}</div>
|
| 94 |
+
<div class="card-title">${esc(title || '')}</div>
|
| 95 |
+
<div class="card-sub">${esc(sub || '')}</div>
|
| 96 |
+
</button>`;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/* ── views ──────────────────────────────────────────────────────────────── */
|
| 100 |
+
const view = $('#view');
|
| 101 |
+
|
| 102 |
+
function markNowPlaying() {
|
| 103 |
+
const vid = state.current && state.current.video_id;
|
| 104 |
+
document.querySelectorAll('.trow, .qrow').forEach(el => {
|
| 105 |
+
const on = vid && el.dataset.vid === vid;
|
| 106 |
+
el.classList.toggle('now', !!on);
|
| 107 |
+
const num = el.querySelector('.idx .num');
|
| 108 |
+
if (num) num.innerHTML = on
|
| 109 |
+
? `<span class="nowbars"><i></i><i></i><i></i></span>`
|
| 110 |
+
: (num.dataset.n || '');
|
| 111 |
+
});
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
function renderHome() {
|
| 115 |
+
const recent = LS.get('recent', []);
|
| 116 |
+
const moods = ['Lo-fi beats', 'Queen', 'Radiohead', '80s hits', 'Taylor Swift', 'Jazz classics',
|
| 117 |
+
'Film scores', 'Kendrick Lamar', 'Daft Punk', 'Acoustic covers', 'Piano study', 'Drum & bass'];
|
| 118 |
+
view.innerHTML = `
|
| 119 |
+
<div class="hero">
|
| 120 |
+
<h1>Search it. Stream it.<br><em>Let the radio take over.</em></h1>
|
| 121 |
+
<p>Cadence plays any song by name from YouTube, then keeps the music going with artist radios and similar-track suggestions.</p>
|
| 122 |
+
<div class="cta">
|
| 123 |
+
<button class="chip strong" data-action="surprise">${icon('spark')} Surprise me</button>
|
| 124 |
+
<button class="chip" data-action="focus-search">${icon('search')} Search a song</button>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
${recent.length ? `<section class="section"><h2>${icon('queue')}Recently played</h2>${trackRows(recent, 'recent')}</section>` : ''}
|
| 128 |
+
<section class="section"><h2>${icon('spark')}Start a station</h2>
|
| 129 |
+
<div class="chips">${moods.map(m => `<button class="chip" data-search="${esc(m)}">${esc(m)}</button>`).join('')}</div>
|
| 130 |
+
</section>`;
|
| 131 |
+
state.viewLists = { recent };
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
async function runSearch(q, { render = true } = {}) {
|
| 135 |
+
if (!q.trim()) return;
|
| 136 |
+
const token = ++state.token;
|
| 137 |
+
$('#q').value = q;
|
| 138 |
+
hideSuggest();
|
| 139 |
+
if (render) view.innerHTML = `<div class="section"><h2>${icon('search')}Searching "${esc(q)}"…</h2>${skeletons(9)}</div>`;
|
| 140 |
+
try {
|
| 141 |
+
const data = await api(`/api/search?q=${encodeURIComponent(q)}&limit=24`);
|
| 142 |
+
if (token !== state.token) return;
|
| 143 |
+
state.lastSearch = data;
|
| 144 |
+
renderSearch(data);
|
| 145 |
+
} catch (e) {
|
| 146 |
+
if (token !== state.token) return;
|
| 147 |
+
view.innerHTML = `<div class="empty"><h3>Search failed</h3><p>${esc(e.message)}</p></div>`;
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function renderSearch(data) {
|
| 152 |
+
const artists = data.artists || [];
|
| 153 |
+
const tracks = data.tracks || [];
|
| 154 |
+
view.innerHTML = `
|
| 155 |
+
${artists.length ? `<section class="section" style="margin-top:0"><h2>${icon('artist')}Artists</h2>
|
| 156 |
+
<div class="cards">${artists.slice(0, 8).map(a => card({
|
| 157 |
+
img: a.thumbnail ? `/api/img?u=${encodeURIComponent(a.thumbnail)}` : '',
|
| 158 |
+
title: a.name,
|
| 159 |
+
sub: a.subscribers ? `${a.subscribers} subscribers` : 'Artist',
|
| 160 |
+
attrs: `data-artist data-artist-id="${esc(a.id)}" data-artist-name="${esc(a.name)}"`,
|
| 161 |
+
iconName: 'artist',
|
| 162 |
+
})).join('')}</div></section>` : ''}
|
| 163 |
+
<section class="section" ${artists.length ? '' : 'style="margin-top:0"'}>
|
| 164 |
+
<h2>${icon('play')}Songs${tracks.length ? ` <span style="color:var(--dim);font-weight:400">(${tracks.length})</span>` : ''}</h2>
|
| 165 |
+
${trackRows(tracks, 'search')}
|
| 166 |
+
</section>`;
|
| 167 |
+
state.viewLists = { search: tracks };
|
| 168 |
+
markNowPlaying();
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
async function openArtist({ id, name }) {
|
| 172 |
+
const token = ++state.token;
|
| 173 |
+
view.innerHTML = `
|
| 174 |
+
<div class="artist-head"><div style="width:154px;height:154px;border-radius:50%;background:#1a1a26"></div>
|
| 175 |
+
<div class="ah-text"><div class="eyebrow">Artist</div><h1>${esc(name || '…')}</h1><div class="stats">Loading…</div></div></div>
|
| 176 |
+
${skeletons(8)}`;
|
| 177 |
+
try {
|
| 178 |
+
const qs = (id && id !== 'undefined') ? `id=${encodeURIComponent(id)}` : `name=${encodeURIComponent(name || '')}`;
|
| 179 |
+
const a = await api(`/api/artist?${qs}`);
|
| 180 |
+
if (token !== state.token) return;
|
| 181 |
+
const listeners = a.monthly_listeners ? `${Number(a.monthly_listeners).toLocaleString()} monthly listeners` : '';
|
| 182 |
+
view.innerHTML = `
|
| 183 |
+
<div class="artist-head">
|
| 184 |
+
<img src="${a.thumbnail ? `/api/img?u=${encodeURIComponent(a.thumbnail)}` : ''}" alt="" onerror="this.style.visibility='hidden'">
|
| 185 |
+
<div class="ah-text">
|
| 186 |
+
<div class="eyebrow">Artist</div>
|
| 187 |
+
<h1>${esc(a.name)}</h1>
|
| 188 |
+
<div class="stats">${[a.subscribers ? `${a.subscribers} subscribers` : '', listeners].filter(Boolean).join(' · ')}</div>
|
| 189 |
+
${a.description ? `<p class="desc">${esc(a.description)}</p>` : ''}
|
| 190 |
+
<div class="chips" style="margin-top:14px">
|
| 191 |
+
<button class="chip strong" data-action="artist-radio" data-artist-id="${esc(a.id)}" data-artist-name="${esc(a.name)}">${icon('spark')} Play artist radio</button>
|
| 192 |
+
${a.top_tracks.length ? `<button class="chip" data-action="play-list" data-list="top">${icon('play')} Play top songs</button>` : ''}
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
<section class="section" style="margin-top:0"><h2>${icon('play')}Top songs</h2>${trackRows(a.top_tracks, 'top')}</section>
|
| 197 |
+
${a.similar && a.similar.length ? `<section class="section"><h2>${icon('artist')}Fans also like</h2>
|
| 198 |
+
<div class="cards">${a.similar.map(s => card({ title: s.name, sub: 'Artist', attrs: `data-artist data-artist-id="${esc(s.id)}" data-artist-name="${esc(s.name)}"`, iconName: 'artist' })).join('')}</div>
|
| 199 |
+
</section>` : ''}
|
| 200 |
+
${a.albums && a.albums.length ? `<section class="section"><h2>${icon('queue')}Albums & singles</h2>
|
| 201 |
+
<div class="cards">${a.albums.map(al => card({ title: al.title, sub: [al.year, al.type].filter(Boolean).join(' · '), attrs: `data-album="${esc(al.id)}"` })).join('')}</div>
|
| 202 |
+
</section>` : ''}`;
|
| 203 |
+
state.viewLists = { top: a.top_tracks };
|
| 204 |
+
state.artist = a;
|
| 205 |
+
markNowPlaying();
|
| 206 |
+
} catch (e) {
|
| 207 |
+
if (token !== state.token) return;
|
| 208 |
+
view.innerHTML = `<div class="empty"><h3>Couldn't load artist</h3><p>${esc(e.message)}</p></div>`;
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
async function openAlbum(id) {
|
| 213 |
+
const token = ++state.token;
|
| 214 |
+
view.innerHTML = `<div class="section"><h2>${icon('queue')}Loading album…</h2>${skeletons(8)}</div>`;
|
| 215 |
+
try {
|
| 216 |
+
const al = await api(`/api/album?id=${encodeURIComponent(id)}`);
|
| 217 |
+
if (token !== state.token) return;
|
| 218 |
+
view.innerHTML = `
|
| 219 |
+
<section class="section" style="margin-top:0">
|
| 220 |
+
<div class="eyebrow">Album</div>
|
| 221 |
+
<h2 style="text-transform:none;font-size:26px;letter-spacing:-.3px;color:var(--text)">${esc(al.title)} ${al.year ? `<span style="color:var(--dim);font-size:15px">· ${al.year}</span>` : ''}</h2>
|
| 222 |
+
<button class="chip strong" data-action="play-list" data-list="album" style="margin:10px 0 16px">${icon('play')} Play album</button>
|
| 223 |
+
${trackRows(al.tracks, 'album')}
|
| 224 |
+
</section>`;
|
| 225 |
+
state.viewLists = { album: al.tracks };
|
| 226 |
+
markNowPlaying();
|
| 227 |
+
} catch (e) {
|
| 228 |
+
if (token !== state.token) return;
|
| 229 |
+
view.innerHTML = `<div class="empty"><h3>Couldn't load album</h3><p>${esc(e.message)}</p></div>`;
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
const LABELS = { search: 'Search results', top: 'Top songs', album: 'Album', recent: 'Recently played' };
|
| 234 |
+
|
| 235 |
+
/* ── playback ───────────────────────────────────────────────────────────── */
|
| 236 |
+
function setQueue(list, startIndex, label) {
|
| 237 |
+
state.queue = list.slice();
|
| 238 |
+
state.queueSource = list;
|
| 239 |
+
state.index = startIndex;
|
| 240 |
+
state.radioLabel = label || null;
|
| 241 |
+
refreshQueue(label);
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
function playTrackAt(i, { addToHistory = true } = {}) {
|
| 245 |
+
if (i < 0 || i >= state.queue.length) return;
|
| 246 |
+
const t = state.queue[i];
|
| 247 |
+
if (addToHistory && state.current) state.history.push(state.index);
|
| 248 |
+
state.index = i;
|
| 249 |
+
state.current = t;
|
| 250 |
+
|
| 251 |
+
$('#npTitle').textContent = t.title;
|
| 252 |
+
$('#npArtist').innerHTML = (t.artists || []).map(artistLink).join(', ') || esc(artistName(t));
|
| 253 |
+
$('#npArt').innerHTML = `<img src="${thumb(t.video_id)}" alt="">`;
|
| 254 |
+
$('#radioBadge').hidden = !state.radioLabel;
|
| 255 |
+
$('#addCurrent').hidden = false;
|
| 256 |
+
document.title = `${t.title} · ${artistName(t)} — Cadence`;
|
| 257 |
+
|
| 258 |
+
setLoading(true);
|
| 259 |
+
audio.src = `/api/stream/${t.video_id}`;
|
| 260 |
+
audio.play().catch(err => {
|
| 261 |
+
setLoading(false);
|
| 262 |
+
if (err && err.name !== 'AbortError') toast('Playback blocked — press play to start', true);
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
const next = state.queue[i + 1];
|
| 266 |
+
if (next) fetch(`/api/prefetch/${next.video_id}`, { method: 'POST' }).catch(() => {});
|
| 267 |
+
|
| 268 |
+
remember(t);
|
| 269 |
+
markNowPlaying();
|
| 270 |
+
refreshQueue();
|
| 271 |
+
maybeGrow();
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
function remember(t) {
|
| 275 |
+
const recent = LS.get('recent', []).filter(x => x.video_id !== t.video_id);
|
| 276 |
+
recent.unshift({ video_id: t.video_id, title: t.title, artists: t.artists, artist_text: t.artist_text, album: t.album, duration: t.duration });
|
| 277 |
+
LS.set('recent', recent.slice(0, 12));
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
function togglePlay() {
|
| 281 |
+
if (!state.current) { toast('Pick a song first 🙂'); return; }
|
| 282 |
+
if (audio.paused) audio.play().catch(() => {}); else audio.pause();
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
function next(auto = false) {
|
| 286 |
+
if (!state.queue.length) return;
|
| 287 |
+
if (state.repeat === 2 && auto) { audio.currentTime = 0; audio.play(); return; }
|
| 288 |
+
let i;
|
| 289 |
+
if (state.shuffle) {
|
| 290 |
+
do { i = Math.floor(Math.random() * state.queue.length); } while (state.queue.length > 1 && i === state.index);
|
| 291 |
+
} else {
|
| 292 |
+
i = state.index + 1;
|
| 293 |
+
}
|
| 294 |
+
if (i >= state.queue.length) {
|
| 295 |
+
if (state.repeat === 1 || auto) {
|
| 296 |
+
// keep the stream alive: grow via radio, otherwise loop
|
| 297 |
+
growQueue().then(grew => {
|
| 298 |
+
if (grew) playTrackAt(state.index + 1);
|
| 299 |
+
else if (state.repeat === 1) playTrackAt(0);
|
| 300 |
+
else { toast('End of queue'); audio.pause(); }
|
| 301 |
+
});
|
| 302 |
+
return;
|
| 303 |
+
}
|
| 304 |
+
playTrackAt(0);
|
| 305 |
+
return;
|
| 306 |
+
}
|
| 307 |
+
playTrackAt(i);
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
function prev() {
|
| 311 |
+
if (audio.currentTime > 4 || !state.queue.length) { audio.currentTime = 0; return; }
|
| 312 |
+
playTrackAt(Math.max(0, state.index - 1));
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
/* radio: keep the queue fed from the track that is playing */
|
| 316 |
+
async function growQueue() {
|
| 317 |
+
if (state.growing) return false;
|
| 318 |
+
const seed = (state.current && state.current.video_id) || null;
|
| 319 |
+
if (!seed) return false;
|
| 320 |
+
state.growing = true;
|
| 321 |
+
try {
|
| 322 |
+
const data = await api(`/api/radio?videoId=${seed}&limit=22`);
|
| 323 |
+
const have = new Set(state.queue.map(t => t.video_id));
|
| 324 |
+
const fresh = (data.tracks || []).filter(t => !have.has(t.video_id));
|
| 325 |
+
if (!fresh.length) return false;
|
| 326 |
+
state.queue.push(...fresh);
|
| 327 |
+
state.radioLabel = 'Radio · ' + (state.current ? artistName(state.current) : 'station');
|
| 328 |
+
refreshQueue();
|
| 329 |
+
$('#radioBadge').hidden = false;
|
| 330 |
+
return true;
|
| 331 |
+
} catch (e) {
|
| 332 |
+
toast('Couldn\'t load suggestions: ' + e.message, true);
|
| 333 |
+
return false;
|
| 334 |
+
} finally {
|
| 335 |
+
state.growing = false;
|
| 336 |
+
}
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
function maybeGrow() {
|
| 340 |
+
if (state.shuffle || state.repeat === 2) return;
|
| 341 |
+
if (state.queue.length - state.index <= 3) growQueue();
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
/* "similar songs": seed the queue with this track + its radio suggestions */
|
| 345 |
+
async function playSimilar(t) {
|
| 346 |
+
toast(`Finding songs similar to "${t.title}"…`);
|
| 347 |
+
try {
|
| 348 |
+
const data = await api(`/api/radio?videoId=${t.video_id}&limit=25`);
|
| 349 |
+
const rest = (data.tracks || []).filter(x => x.video_id !== t.video_id);
|
| 350 |
+
if (!rest.length) throw new Error('no suggestions came back');
|
| 351 |
+
setQueue([t, ...rest], 0, `Similar to ${t.title}`);
|
| 352 |
+
playTrackAt(0);
|
| 353 |
+
toast(`▶ ${rest.length} songs similar to ${artistName(t)} queued`);
|
| 354 |
+
} catch (e) {
|
| 355 |
+
toast('Couldn\'t get similar songs: ' + e.message, true);
|
| 356 |
+
}
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
async function playArtistRadio({ id, name }) {
|
| 360 |
+
toast(`Tuning ${name} radio…`);
|
| 361 |
+
try {
|
| 362 |
+
const data = await api(`/api/artist/radio?id=${encodeURIComponent(id)}&limit=30`);
|
| 363 |
+
const tracks = data.tracks || [];
|
| 364 |
+
if (!tracks.length) throw new Error('station came back empty');
|
| 365 |
+
setQueue(tracks, 0, `Artist radio · ${data.station_name || name}`);
|
| 366 |
+
playTrackAt(0);
|
| 367 |
+
toast(`▶ ${data.station_name || name} radio — ${tracks.length} tracks`);
|
| 368 |
+
} catch (e) {
|
| 369 |
+
toast('Radio failed: ' + e.message, true);
|
| 370 |
+
}
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
/* ── queue panel ────────────────────────────────────────────────────────── */
|
| 374 |
+
function refreshQueue(label) {
|
| 375 |
+
const list = $('#queueList');
|
| 376 |
+
if (!state.queue.length) {
|
| 377 |
+
list.innerHTML = `<div class="queue-empty">Nothing queued yet.<br>Play a song and Cadence will suggest what's next.</div>`;
|
| 378 |
+
$('#queueSub').textContent = 'Suggestions appear as you listen';
|
| 379 |
+
return;
|
| 380 |
+
}
|
| 381 |
+
$('#queueTitle').textContent = label || state.radioLabel || 'Up next';
|
| 382 |
+
$('#queueSub').textContent = `Track ${state.index + 1} of ${state.queue.length}`;
|
| 383 |
+
list.innerHTML = state.queue.map((t, i) => `
|
| 384 |
+
<li class="qrow ${state.current && t.video_id === state.current.video_id ? 'now' : ''}" data-vid="${esc(t.video_id)}" data-qi="${i}">
|
| 385 |
+
<img src="${thumb(t.video_id)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">
|
| 386 |
+
<div><div class="qt">${esc(t.title)}</div><div class="qa">${esc(artistName(t))}</div></div>
|
| 387 |
+
<span class="qd">${esc(fmtTime(t.duration))}</span>
|
| 388 |
+
</li>`).join('');
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
/* ── audio element plumbing ─────────────────────────────────────────────── */
|
| 392 |
+
function setLoading(on) { $('#spinner').hidden = !on; }
|
| 393 |
+
|
| 394 |
+
audio.addEventListener('playing', () => { setLoading(false); setPlayIcon(true); markNowPlaying(); growAhead(); });
|
| 395 |
+
audio.addEventListener('pause', () => setPlayIcon(false));
|
| 396 |
+
audio.addEventListener('waiting', () => setLoading(true));
|
| 397 |
+
audio.addEventListener('canplay', () => setLoading(false));
|
| 398 |
+
audio.addEventListener('timeupdate', () => {
|
| 399 |
+
const d = audio.duration || (state.current && state.current.duration) || 0;
|
| 400 |
+
const p = d ? (audio.currentTime / d) : 0;
|
| 401 |
+
$('#tCur').textContent = fmtTime(audio.currentTime);
|
| 402 |
+
$('#tDur').textContent = fmtTime(d);
|
| 403 |
+
if (!seeking) { $('#seek').value = Math.round(p * 1000); $('#seekFill').style.width = (p * 100) + '%'; }
|
| 404 |
+
});
|
| 405 |
+
audio.addEventListener('ended', () => next(true));
|
| 406 |
+
let errStreak = 0;
|
| 407 |
+
audio.addEventListener('error', () => {
|
| 408 |
+
setLoading(false);
|
| 409 |
+
if (!state.current) return;
|
| 410 |
+
if (++errStreak > 3) { toast('Several tracks failed to stream — pausing', true); audio.pause(); return; }
|
| 411 |
+
toast(`Couldn't stream "${state.current.title}" — skipping`, true);
|
| 412 |
+
setTimeout(() => next(true), 700);
|
| 413 |
+
});
|
| 414 |
+
audio.addEventListener('playing', () => { errStreak = 0; });
|
| 415 |
+
audio.volume = LS.get('volume', 0.9);
|
| 416 |
+
$('#vol').value = audio.volume;
|
| 417 |
+
|
| 418 |
+
function setPlayIcon(playing) {
|
| 419 |
+
$('#playBtn').innerHTML = icon(playing ? 'pause' : 'play');
|
| 420 |
+
$('#playBtn').title = playing ? 'Pause (Space)' : 'Play (Space)';
|
| 421 |
+
}
|
| 422 |
+
setPlayIcon(false);
|
| 423 |
+
|
| 424 |
+
function growAhead() {
|
| 425 |
+
// room remaining in queue? top it up with similar tracks in the background
|
| 426 |
+
if (state.queue.length - state.index <= 3) growQueue();
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
/* seek */
|
| 430 |
+
let seeking = false;
|
| 431 |
+
const seek = $('#seek');
|
| 432 |
+
['input', 'change'].forEach(ev => seek.addEventListener(ev, () => {
|
| 433 |
+
seeking = ev === 'input' ? true : seeking;
|
| 434 |
+
const d = audio.duration || 0;
|
| 435 |
+
if (d) {
|
| 436 |
+
const t = (seek.value / 1000) * d;
|
| 437 |
+
$('#tCur').textContent = fmtTime(t);
|
| 438 |
+
$('#seekFill').style.width = (seek.value / 10) + '%';
|
| 439 |
+
$('#tDur').textContent = fmtTime(d);
|
| 440 |
+
}
|
| 441 |
+
if (ev === 'change') { if (d) audio.currentTime = (seek.value / 1000) * d; seeking = false; }
|
| 442 |
+
}));
|
| 443 |
+
seek.addEventListener('pointerup', () => { seeking = false; });
|
| 444 |
+
|
| 445 |
+
/* volume / mute */
|
| 446 |
+
$('#vol').addEventListener('input', e => {
|
| 447 |
+
audio.volume = Number(e.target.value);
|
| 448 |
+
if (audio.volume > 0) audio.muted = false;
|
| 449 |
+
LS.set('volume', audio.volume);
|
| 450 |
+
syncMute();
|
| 451 |
+
});
|
| 452 |
+
function syncMute() { $('#muteBtn').innerHTML = icon(audio.muted || audio.volume === 0 ? 'mute' : 'vol'); }
|
| 453 |
+
$('#muteBtn').addEventListener('click', () => { audio.muted = !audio.muted; syncMute(); });
|
| 454 |
+
syncMute();
|
| 455 |
+
|
| 456 |
+
/* transport buttons */
|
| 457 |
+
$('#playBtn').addEventListener('click', togglePlay);
|
| 458 |
+
$('#nextBtn').addEventListener('click', () => next(false));
|
| 459 |
+
$('#prevBtn').addEventListener('click', prev);
|
| 460 |
+
$('#shuffleBtn').addEventListener('click', e => {
|
| 461 |
+
state.shuffle = !state.shuffle;
|
| 462 |
+
e.currentTarget.classList.toggle('on', state.shuffle);
|
| 463 |
+
toast(state.shuffle ? 'Shuffle on' : 'Shuffle off');
|
| 464 |
+
});
|
| 465 |
+
$('#repeatBtn').addEventListener('click', e => {
|
| 466 |
+
state.repeat = (state.repeat + 1) % 3;
|
| 467 |
+
e.currentTarget.classList.toggle('on', state.repeat > 0);
|
| 468 |
+
e.currentTarget.innerHTML = icon(state.repeat === 2 ? 'repeat1' : 'repeat');
|
| 469 |
+
toast(['Repeat off', 'Repeat queue', 'Repeat track'][state.repeat]);
|
| 470 |
+
});
|
| 471 |
+
$('#addCurrent').addEventListener('click', () => {
|
| 472 |
+
if (!state.current) return;
|
| 473 |
+
state.queue.push(state.current);
|
| 474 |
+
refreshQueue();
|
| 475 |
+
toast('Re-queued ' + state.current.title);
|
| 476 |
+
});
|
| 477 |
+
|
| 478 |
+
/* ── events: view ───────────────────────────────────────────────────────── */
|
| 479 |
+
view.addEventListener('click', e => {
|
| 480 |
+
const artistEl = e.target.closest('[data-artist]');
|
| 481 |
+
if (artistEl) { e.preventDefault(); openArtist({ id: artistEl.dataset.artistId || '', name: artistEl.dataset.artistName || '' }); return; }
|
| 482 |
+
|
| 483 |
+
const albumEl = e.target.closest('[data-album]');
|
| 484 |
+
if (albumEl) { e.preventDefault(); openAlbum(albumEl.dataset.album); return; }
|
| 485 |
+
|
| 486 |
+
const act = e.target.closest('[data-action]');
|
| 487 |
+
if (act) {
|
| 488 |
+
const a = act.dataset.action;
|
| 489 |
+
if (a === 'queue') {
|
| 490 |
+
e.stopPropagation();
|
| 491 |
+
const row = e.target.closest('.trow');
|
| 492 |
+
const list = state.viewLists[row.dataset.list] || [];
|
| 493 |
+
const t = list[Number(row.dataset.i)];
|
| 494 |
+
if (t) { state.queue.push(t); refreshQueue(); toast(`Added "${t.title}" to queue`); }
|
| 495 |
+
return;
|
| 496 |
+
}
|
| 497 |
+
if (a === 'similar') {
|
| 498 |
+
e.stopPropagation();
|
| 499 |
+
const row = e.target.closest('.trow');
|
| 500 |
+
const list = state.viewLists[row.dataset.list] || [];
|
| 501 |
+
const t = list[Number(row.dataset.i)];
|
| 502 |
+
if (t) playSimilar(t);
|
| 503 |
+
return;
|
| 504 |
+
}
|
| 505 |
+
if (a === 'play-list') {
|
| 506 |
+
const list = state.viewLists[act.dataset.list] || [];
|
| 507 |
+
if (list.length) { setQueue(list, 0, 'Playing list'); playTrackAt(0); }
|
| 508 |
+
return;
|
| 509 |
+
}
|
| 510 |
+
if (a === 'artist-radio') {
|
| 511 |
+
playArtistRadio({ id: act.dataset.artistId, name: act.dataset.artistName });
|
| 512 |
+
return;
|
| 513 |
+
}
|
| 514 |
+
if (a === 'surprise') {
|
| 515 |
+
const picks = ['Lo-fi hip hop', 'Daft Punk', 'Fleetwood Mac', 'Kendrick Lamar', 'Tame Impala', 'Nina Simone', 'The Weeknd', 'Radiohead'];
|
| 516 |
+
runSearch(picks[Math.floor(Math.random() * picks.length)]);
|
| 517 |
+
return;
|
| 518 |
+
}
|
| 519 |
+
if (a === 'focus-search') { $('#q').focus(); return; }
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
const chip = e.target.closest('[data-search]');
|
| 523 |
+
if (chip) { runSearch(chip.dataset.search); return; }
|
| 524 |
+
|
| 525 |
+
const row = e.target.closest('.trow');
|
| 526 |
+
if (row) {
|
| 527 |
+
const listName = row.dataset.list;
|
| 528 |
+
const list = state.viewLists[listName] || [];
|
| 529 |
+
const i = Number(row.dataset.i);
|
| 530 |
+
if (list.length) {
|
| 531 |
+
const label = LABELS[listName] || 'Playing list';
|
| 532 |
+
if (state.queueSource !== list) setQueue(list, i, label);
|
| 533 |
+
playTrackAt(i);
|
| 534 |
+
}
|
| 535 |
+
}
|
| 536 |
+
});
|
| 537 |
+
|
| 538 |
+
/* ── events: queue panel ────────────────────────────────────────────────── */
|
| 539 |
+
$('#queueList').addEventListener('click', e => {
|
| 540 |
+
const row = e.target.closest('.qrow');
|
| 541 |
+
if (row) playTrackAt(Number(row.dataset.qi));
|
| 542 |
+
});
|
| 543 |
+
$('#clearQueue').addEventListener('click', () => {
|
| 544 |
+
const keep = state.current;
|
| 545 |
+
state.queue = keep ? [keep] : [];
|
| 546 |
+
state.index = keep ? 0 : -1;
|
| 547 |
+
state.radioLabel = null;
|
| 548 |
+
$('#radioBadge').hidden = true;
|
| 549 |
+
refreshQueue();
|
| 550 |
+
toast('Queue cleared');
|
| 551 |
+
});
|
| 552 |
+
$('#queueToggle').addEventListener('click', e => {
|
| 553 |
+
const isNarrow = window.matchMedia('(max-width:900px)').matches;
|
| 554 |
+
const panel = $('#queuePanel');
|
| 555 |
+
if (isNarrow) { panel.classList.toggle('open'); }
|
| 556 |
+
else { $('.content').classList.toggle('queue-hidden'); }
|
| 557 |
+
e.currentTarget.classList.toggle('active');
|
| 558 |
+
});
|
| 559 |
+
|
| 560 |
+
/* ── search box + suggestions ───────────────────────────────────────────── */
|
| 561 |
+
let searchTimer = null, suggestTimer = null;
|
| 562 |
+
const qInput = $('#q');
|
| 563 |
+
|
| 564 |
+
qInput.addEventListener('input', () => {
|
| 565 |
+
const v = qInput.value.trim();
|
| 566 |
+
clearTimeout(suggestTimer);
|
| 567 |
+
if (v.length < 2) { hideSuggest(); return; }
|
| 568 |
+
suggestTimer = setTimeout(fetchSuggestions, 160);
|
| 569 |
+
});
|
| 570 |
+
|
| 571 |
+
qInput.addEventListener('keydown', e => {
|
| 572 |
+
const box = $('#suggest');
|
| 573 |
+
const open = !box.hidden && state.suggestItems.length;
|
| 574 |
+
if (e.key === 'ArrowDown' && open) { e.preventDefault(); state.suggestActive = Math.min(state.suggestActive + 1, state.suggestItems.length - 1); paintSuggest(); }
|
| 575 |
+
else if (e.key === 'ArrowUp' && open) { e.preventDefault(); state.suggestActive = Math.max(state.suggestActive - 1, -1); paintSuggest(); }
|
| 576 |
+
else if (e.key === 'Enter') {
|
| 577 |
+
if (open && state.suggestActive >= 0) runSearch(state.suggestItems[state.suggestActive]);
|
| 578 |
+
else runSearch(qInput.value);
|
| 579 |
+
} else if (e.key === 'Escape') hideSuggest();
|
| 580 |
+
});
|
| 581 |
+
|
| 582 |
+
async function fetchSuggestions() {
|
| 583 |
+
const v = qInput.value.trim();
|
| 584 |
+
if (v.length < 2) return;
|
| 585 |
+
try {
|
| 586 |
+
const data = await api(`/api/suggest?q=${encodeURIComponent(v)}`);
|
| 587 |
+
if (qInput.value.trim() !== v) return;
|
| 588 |
+
state.suggestItems = data.suggestions || [];
|
| 589 |
+
state.suggestActive = -1;
|
| 590 |
+
paintSuggest();
|
| 591 |
+
} catch {}
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
function paintSuggest() {
|
| 595 |
+
const box = $('#suggest');
|
| 596 |
+
if (!state.suggestItems.length) { hideSuggest(); return; }
|
| 597 |
+
box.innerHTML = state.suggestItems.map((s, i) =>
|
| 598 |
+
`<div class="${i === state.suggestActive ? 'active' : ''}" data-s="${esc(s)}">${icon('search')}<span>${esc(s)}</span></div>`).join('');
|
| 599 |
+
box.hidden = false;
|
| 600 |
+
}
|
| 601 |
+
function hideSuggest() { $('#suggest').hidden = true; state.suggestActive = -1; }
|
| 602 |
+
$('#suggest').addEventListener('mousedown', e => {
|
| 603 |
+
const el = e.target.closest('[data-s]');
|
| 604 |
+
if (el) { e.preventDefault(); runSearch(el.dataset.s); }
|
| 605 |
+
});
|
| 606 |
+
document.addEventListener('click', e => { if (!e.target.closest('.searchwrap')) hideSuggest(); });
|
| 607 |
+
|
| 608 |
+
/* ── keyboard shortcuts ─────────────────────────────────────────────────── */
|
| 609 |
+
document.addEventListener('keydown', e => {
|
| 610 |
+
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName);
|
| 611 |
+
if (e.key === '/' && !typing) { e.preventDefault(); qInput.focus(); qInput.select(); return; }
|
| 612 |
+
if (typing || e.metaKey || e.ctrlKey || e.altKey) return;
|
| 613 |
+
switch (e.key.toLowerCase()) {
|
| 614 |
+
case ' ': e.preventDefault(); togglePlay(); break;
|
| 615 |
+
case 'n': next(false); break;
|
| 616 |
+
case 'p': prev(); break;
|
| 617 |
+
case 's': $('#shuffleBtn').click(); break;
|
| 618 |
+
case 'r': $('#repeatBtn').click(); break;
|
| 619 |
+
case 'm': $('#muteBtn').click(); break;
|
| 620 |
+
case 'q': $('#queueToggle').click(); break;
|
| 621 |
+
case 'arrowright': audio.currentTime = Math.min((audio.duration || 0), audio.currentTime + 10); break;
|
| 622 |
+
case 'arrowleft': audio.currentTime = Math.max(0, audio.currentTime - 10); break;
|
| 623 |
+
}
|
| 624 |
+
});
|
| 625 |
+
|
| 626 |
+
/* media session (lockscreen / headset buttons) */
|
| 627 |
+
if ('mediaSession' in navigator) {
|
| 628 |
+
navigator.mediaSession.setActionHandler('play', togglePlay);
|
| 629 |
+
navigator.mediaSession.setActionHandler('pause', togglePlay);
|
| 630 |
+
navigator.mediaSession.setActionHandler('nexttrack', () => next(false));
|
| 631 |
+
navigator.mediaSession.setActionHandler('previoustrack', prev);
|
| 632 |
+
}
|
| 633 |
+
function updateMediaSession(t) {
|
| 634 |
+
if (!('mediaSession' in navigator) || !t) return;
|
| 635 |
+
navigator.mediaSession.metadata = new MediaMetadata({
|
| 636 |
+
title: t.title, artist: artistName(t), album: (t.album && t.album.name) || 'Cadence',
|
| 637 |
+
artwork: [{ src: thumb(t.video_id), sizes: '320x180', type: 'image/jpeg' }],
|
| 638 |
+
});
|
| 639 |
+
}
|
| 640 |
+
const _playTrackAt = playTrackAt;
|
| 641 |
+
playTrackAt = function (i, opts) { _playTrackAt(i, opts); updateMediaSession(state.current); };
|
| 642 |
+
|
| 643 |
+
/* ── boot ───────────────────────────────────────────────────────────────── */
|
| 644 |
+
$('#brandLink').addEventListener('click', e => { e.preventDefault(); state.token++; history.replaceState(null, '', '/'); renderHome(); });
|
| 645 |
+
window.addEventListener('popstate', () => renderHome());
|
| 646 |
+
renderHome();
|
| 647 |
+
console.log('%c🎧 Cadence ready', 'color:#a78bfa;font-weight:bold');
|
| 648 |
+
|
| 649 |
+
})();
|
static/dash.css
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ── Cadence ── dark, glassy music player ─────────────────────────────── */
|
| 2 |
+
:root{
|
| 3 |
+
--bg:#08080d;
|
| 4 |
+
--panel:rgba(20,20,30,.72);
|
| 5 |
+
--card:rgba(255,255,255,.045);
|
| 6 |
+
--card-hover:rgba(255,255,255,.085);
|
| 7 |
+
--line:rgba(255,255,255,.09);
|
| 8 |
+
--text:#eef0f7;
|
| 9 |
+
--muted:#969db4;
|
| 10 |
+
--dim:#6b7188;
|
| 11 |
+
--accent:#8b5cf6;
|
| 12 |
+
--accent-2:#22d3ee;
|
| 13 |
+
--accent-soft:rgba(139,92,246,.16);
|
| 14 |
+
--radius:16px;
|
| 15 |
+
--shadow:0 18px 50px -18px rgba(0,0,0,.85);
|
| 16 |
+
--player-h:96px;
|
| 17 |
+
}
|
| 18 |
+
*{box-sizing:border-box}
|
| 19 |
+
html,body{height:100%}
|
| 20 |
+
body{
|
| 21 |
+
margin:0;background:var(--bg);color:var(--text);
|
| 22 |
+
font:15px/1.45 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
| 23 |
+
-webkit-font-smoothing:antialiased;overflow:hidden;
|
| 24 |
+
}
|
| 25 |
+
body::before,body::after{
|
| 26 |
+
content:"";position:fixed;inset:auto;border-radius:50%;filter:blur(90px);pointer-events:none;z-index:0;
|
| 27 |
+
}
|
| 28 |
+
body::before{width:52vw;height:52vw;left:-14vw;top:-18vw;background:radial-gradient(circle at 30% 30%,rgba(139,92,246,.42),transparent 62%)}
|
| 29 |
+
body::after{width:46vw;height:46vw;right:-12vw;bottom:-16vw;background:radial-gradient(circle at 60% 60%,rgba(34,211,238,.30),transparent 62%)}
|
| 30 |
+
svg{width:20px;height:20px;fill:currentColor;flex:0 0 auto}
|
| 31 |
+
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
| 32 |
+
input[type=range]{appearance:none;-webkit-appearance:none;background:transparent;margin:0;cursor:pointer}
|
| 33 |
+
::-webkit-scrollbar{width:10px;height:10px}
|
| 34 |
+
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:99px;border:3px solid transparent;background-clip:content-box}
|
| 35 |
+
::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.26);background-clip:content-box}
|
| 36 |
+
|
| 37 |
+
.app{position:relative;z-index:1;height:100dvh;display:grid;grid-template-rows:auto minmax(0,1fr) auto}
|
| 38 |
+
|
| 39 |
+
/* ── top bar ── */
|
| 40 |
+
.topbar{
|
| 41 |
+
display:flex;align-items:center;gap:18px;padding:14px 22px;
|
| 42 |
+
border-bottom:1px solid var(--line);backdrop-filter:blur(18px);
|
| 43 |
+
background:linear-gradient(180deg,rgba(12,12,20,.85),rgba(10,10,16,.6));
|
| 44 |
+
}
|
| 45 |
+
.brand{display:flex;align-items:center;gap:11px;text-decoration:none;color:inherit;flex:0 0 auto}
|
| 46 |
+
.brand-mark{display:flex;align-items:flex-end;gap:3px;height:24px}
|
| 47 |
+
.brand-mark span{width:4px;border-radius:3px;background:linear-gradient(180deg,var(--accent-2),var(--accent));animation:eq 1.15s ease-in-out infinite}
|
| 48 |
+
.brand-mark span:nth-child(1){height:12px;animation-delay:-.35s}
|
| 49 |
+
.brand-mark span:nth-child(2){height:22px;animation-delay:-.7s}
|
| 50 |
+
.brand-mark span:nth-child(3){height:16px}
|
| 51 |
+
.brand-name{font-weight:700;font-size:19px;letter-spacing:.2px}
|
| 52 |
+
@keyframes eq{0%,100%{transform:scaleY(.45)}50%{transform:scaleY(1)}}
|
| 53 |
+
|
| 54 |
+
.searchwrap{position:relative;flex:1 1 auto;max-width:620px;display:flex;align-items:center;margin:0 auto}
|
| 55 |
+
.searchwrap .sicon{position:absolute;left:14px;color:var(--dim);width:18px;height:18px}
|
| 56 |
+
.searchwrap input{
|
| 57 |
+
width:100%;padding:12px 44px 12px 42px;border-radius:999px;border:1px solid var(--line);
|
| 58 |
+
background:rgba(255,255,255,.05);color:var(--text);outline:none;font-size:14.5px;
|
| 59 |
+
transition:border-color .15s,background .15s,box-shadow .15s;
|
| 60 |
+
}
|
| 61 |
+
.searchwrap input:focus{border-color:rgba(139,92,246,.65);background:rgba(255,255,255,.08);box-shadow:0 0 0 4px var(--accent-soft)}
|
| 62 |
+
.searchwrap input::placeholder{color:var(--dim)}
|
| 63 |
+
.searchwrap input::-webkit-search-cancel-button{filter:invert(.6)}
|
| 64 |
+
.searchwrap kbd{
|
| 65 |
+
position:absolute;right:12px;font-size:11px;color:var(--dim);border:1px solid var(--line);
|
| 66 |
+
border-radius:6px;padding:2px 7px;background:rgba(255,255,255,.04)
|
| 67 |
+
}
|
| 68 |
+
.searchwrap:focus-within kbd{opacity:0}
|
| 69 |
+
|
| 70 |
+
.suggest{
|
| 71 |
+
position:absolute;top:calc(100% + 8px);left:0;right:0;z-index:60;
|
| 72 |
+
background:rgba(18,18,28,.97);border:1px solid var(--line);border-radius:14px;
|
| 73 |
+
box-shadow:var(--shadow);overflow:hidden;backdrop-filter:blur(20px)
|
| 74 |
+
}
|
| 75 |
+
.suggest div{padding:10px 16px;font-size:14px;color:var(--muted);cursor:pointer;display:flex;gap:10px;align-items:center}
|
| 76 |
+
.suggest div svg{width:15px;height:15px;color:var(--dim)}
|
| 77 |
+
.suggest div:hover,.suggest div.active{background:rgba(139,92,246,.18);color:var(--text)}
|
| 78 |
+
|
| 79 |
+
.ghost-btn{
|
| 80 |
+
display:flex;align-items:center;gap:8px;padding:9px 15px;border-radius:999px;
|
| 81 |
+
border:1px solid var(--line);color:var(--muted);background:rgba(255,255,255,.03);font-size:13.5px;
|
| 82 |
+
transition:.15s;flex:0 0 auto
|
| 83 |
+
}
|
| 84 |
+
.ghost-btn:hover{color:var(--text);background:rgba(255,255,255,.08)}
|
| 85 |
+
.ghost-btn.active{color:var(--text);border-color:rgba(139,92,246,.5);background:var(--accent-soft)}
|
| 86 |
+
|
| 87 |
+
/* ── content / views ── */
|
| 88 |
+
.content{display:grid;grid-template-columns:minmax(0,1fr) 336px;min-height:0;transition:grid-template-columns .22s ease}
|
| 89 |
+
.content.queue-hidden{grid-template-columns:minmax(0,1fr) 0px}
|
| 90 |
+
.view{overflow-y:auto;padding:26px 28px 40px;min-height:0}
|
| 91 |
+
.queue-panel{
|
| 92 |
+
overflow:hidden;border-left:1px solid var(--line);background:var(--panel);
|
| 93 |
+
display:flex;flex-direction:column;min-height:0;backdrop-filter:blur(20px)
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.hero{max-width:760px;margin:4vh auto 0;text-align:center}
|
| 97 |
+
.hero h1{font-size:clamp(30px,4.4vw,50px);line-height:1.08;margin:0 0 14px;font-weight:800;letter-spacing:-.5px}
|
| 98 |
+
.hero h1 em{font-style:normal;background:linear-gradient(96deg,var(--accent-2),var(--accent) 60%,#f472b6);-webkit-background-clip:text;background-clip:text;color:transparent}
|
| 99 |
+
.hero p{color:var(--muted);margin:0 auto 26px;max-width:520px;font-size:15.5px}
|
| 100 |
+
.hero .cta{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
|
| 101 |
+
|
| 102 |
+
.section{margin:34px 0 0}
|
| 103 |
+
.section h2{font-size:15px;margin:0 0 14px;letter-spacing:.4px;text-transform:uppercase;color:var(--muted);font-weight:600;display:flex;align-items:center;gap:9px}
|
| 104 |
+
.section h2 svg{width:16px;height:16px;color:var(--accent)}
|
| 105 |
+
.chips{display:flex;flex-wrap:wrap;gap:9px}
|
| 106 |
+
.chip{
|
| 107 |
+
padding:8px 15px;border-radius:999px;border:1px solid var(--line);background:var(--card);
|
| 108 |
+
font-size:13.5px;color:var(--muted);transition:.15s;display:inline-flex;gap:8px;align-items:center
|
| 109 |
+
}
|
| 110 |
+
.chip:hover{color:var(--text);background:var(--card-hover);transform:translateY(-1px)}
|
| 111 |
+
.chip.strong{color:var(--text);border-color:rgba(139,92,246,.45);background:var(--accent-soft)}
|
| 112 |
+
.chip small{color:var(--dim)}
|
| 113 |
+
|
| 114 |
+
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px}
|
| 115 |
+
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:12px;cursor:pointer;transition:.16s;text-align:left;width:100%}
|
| 116 |
+
.card:hover{background:var(--card-hover);transform:translateY(-2px);border-color:rgba(139,92,246,.35)}
|
| 117 |
+
.card-art{aspect-ratio:1/1;border-radius:10px;overflow:hidden;margin-bottom:10px;background:linear-gradient(140deg,#22223a,#14141f);display:grid;place-items:center}
|
| 118 |
+
.card-art img{width:100%;height:100%;object-fit:cover;display:block}
|
| 119 |
+
.card-art svg{width:34px;height:34px;color:var(--dim)}
|
| 120 |
+
.card-title{font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 121 |
+
.card-sub{font-size:12px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
|
| 122 |
+
|
| 123 |
+
/* artist header */
|
| 124 |
+
.artist-head{display:flex;gap:22px;align-items:flex-end;padding:6px 0 22px;border-bottom:1px solid var(--line);margin-bottom:24px;flex-wrap:wrap}
|
| 125 |
+
.artist-head img{width:154px;height:154px;border-radius:50%;object-fit:cover;box-shadow:var(--shadow);background:#1a1a26}
|
| 126 |
+
.artist-head .ah-text{flex:1 1 320px;min-width:0}
|
| 127 |
+
.eyebrow{font-size:12px;letter-spacing:1.4px;text-transform:uppercase;color:var(--accent-2);font-weight:700}
|
| 128 |
+
.artist-head h1{font-size:clamp(28px,4vw,42px);margin:6px 0 8px;font-weight:800;letter-spacing:-.4px}
|
| 129 |
+
.artist-head .stats{color:var(--muted);font-size:13.5px;margin-bottom:14px}
|
| 130 |
+
.artist-head .desc{color:var(--dim);font-size:13px;max-width:640px;max-height:3.9em;overflow:hidden}
|
| 131 |
+
|
| 132 |
+
/* track list */
|
| 133 |
+
.tracks{display:flex;flex-direction:column;gap:2px;margin-top:4px}
|
| 134 |
+
.trow{
|
| 135 |
+
display:grid;grid-template-columns:34px 46px minmax(0,2.3fr) minmax(0,1fr) auto;align-items:center;gap:12px;
|
| 136 |
+
padding:7px 12px;border-radius:11px;cursor:pointer;transition:background .12s;position:relative
|
| 137 |
+
}
|
| 138 |
+
.trow:hover{background:var(--card-hover)}
|
| 139 |
+
.trow.now{background:var(--accent-soft);box-shadow:inset 0 0 0 1px rgba(139,92,246,.3)}
|
| 140 |
+
/* rows shown without an album column shouldn't reserve space for one */
|
| 141 |
+
.tracks.compact .trow{grid-template-columns:34px 46px minmax(0,1fr) auto}
|
| 142 |
+
.tracks.compact .trow .album{display:none}
|
| 143 |
+
/* the album column is the first thing to go when a panel is narrow */
|
| 144 |
+
.grid2 .trow,.queue-panel .trow{grid-template-columns:30px 42px minmax(0,1.9fr) auto}
|
| 145 |
+
.grid2 .trow .album,.grid2 .tracks .trow .album{display:none}
|
| 146 |
+
.trow .idx{color:var(--dim);font-size:13px;font-variant-numeric:tabular-nums;text-align:right;position:relative}
|
| 147 |
+
.trow .idx .playicon{position:absolute;inset:0;display:none;place-items:center}
|
| 148 |
+
.trow .idx .playicon svg{width:15px;height:15px;color:var(--text)}
|
| 149 |
+
.trow:hover .idx .num{display:none}
|
| 150 |
+
.trow:hover .idx .playicon{display:grid}
|
| 151 |
+
.trow .art{width:46px;height:46px;border-radius:8px;object-fit:cover;background:#1c1c28;display:block}
|
| 152 |
+
.trow .meta{min-width:0}
|
| 153 |
+
.trow .tt{font-size:14px;font-weight:550;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 154 |
+
.trow .ta{font-size:12.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
|
| 155 |
+
.trow .ta a{color:inherit;text-decoration:none}
|
| 156 |
+
.trow .ta a:hover{color:var(--accent-2);text-decoration:underline}
|
| 157 |
+
.trow .album{font-size:12.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 158 |
+
.trow .album a{color:inherit;text-decoration:none}
|
| 159 |
+
.trow .album a:hover{color:var(--accent-2);text-decoration:underline}
|
| 160 |
+
.trow .end{display:flex;align-items:center;gap:6px;color:var(--dim);font-size:12.5px}
|
| 161 |
+
.trow .addq{opacity:0;transition:.12s}
|
| 162 |
+
.trow:hover .addq{opacity:1}
|
| 163 |
+
.trow .nowbars{display:flex;align-items:flex-end;gap:2px;height:15px;width:15px}
|
| 164 |
+
.trow .nowbars i{width:3px;background:var(--accent-2);border-radius:2px;height:100%;animation:eq .9s ease-in-out infinite;transform-origin:bottom}
|
| 165 |
+
.trow .nowbars i:nth-child(2){animation-delay:-.3s}
|
| 166 |
+
.trow .nowbars i:nth-child(3){animation-delay:-.6s}
|
| 167 |
+
.playing .nowbars i{animation-play-state:running}
|
| 168 |
+
|
| 169 |
+
.search-empty{display:flex;flex-direction:column;align-items:center;text-align:center;gap:6px;padding:34px 22px;max-width:560px;margin:0 auto}
|
| 170 |
+
.search-empty .se-icon{width:52px;height:52px;border-radius:50%;display:grid;place-items:center;margin-bottom:6px;
|
| 171 |
+
background:linear-gradient(140deg,rgba(139,92,246,.28),rgba(34,211,238,.18));border:1px solid rgba(139,92,246,.35)}
|
| 172 |
+
.search-empty .se-icon svg{width:22px;height:22px;color:var(--accent-2)}
|
| 173 |
+
.search-empty h2{margin:0;font-size:17px}
|
| 174 |
+
.search-empty p{margin:2px 0 14px;color:var(--dim);font-size:13px;line-height:1.65}
|
| 175 |
+
|
| 176 |
+
/* [hidden] must always win over component display rules */
|
| 177 |
+
[hidden]{display:none!important}
|
| 178 |
+
|
| 179 |
+
/* queue panel */
|
| 180 |
+
.queue-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding:20px 20px 14px;border-bottom:1px solid var(--line)}
|
| 181 |
+
.queue-head h2{font-size:15px;margin:0;font-weight:700}
|
| 182 |
+
.queue-head p{margin:4px 0 0;font-size:12px;color:var(--dim)}
|
| 183 |
+
.queue-list{list-style:none;margin:0;padding:10px;overflow-y:auto;min-height:0;flex:1}
|
| 184 |
+
.qrow{display:grid;grid-template-columns:38px minmax(0,1fr) auto;gap:10px;align-items:center;padding:7px 9px;border-radius:10px;cursor:pointer}
|
| 185 |
+
.qrow:hover{background:var(--card-hover)}
|
| 186 |
+
.qrow.now{background:var(--accent-soft)}
|
| 187 |
+
.qrow img{width:38px;height:38px;border-radius:7px;object-fit:cover;background:#1c1c28}
|
| 188 |
+
.qrow .qt{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 189 |
+
.qrow .qa{font-size:11.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 190 |
+
.qrow .qd{font-size:11.5px;color:var(--dim);font-variant-numeric:tabular-nums}
|
| 191 |
+
.queue-empty{padding:26px 20px;color:var(--dim);font-size:13px;text-align:center;line-height:1.7}
|
| 192 |
+
|
| 193 |
+
/* ── player bar ── */
|
| 194 |
+
.player{
|
| 195 |
+
display:grid;grid-template-columns:minmax(0,1fr) minmax(300px,1.5fr) minmax(0,1fr);align-items:center;gap:20px;
|
| 196 |
+
padding:12px 22px;border-top:1px solid var(--line);background:rgba(12,12,20,.86);backdrop-filter:blur(22px);min-height:var(--player-h)
|
| 197 |
+
}
|
| 198 |
+
.np{display:flex;align-items:center;gap:13px;min-width:0}
|
| 199 |
+
.np-art{
|
| 200 |
+
width:54px;height:54px;border-radius:10px;overflow:hidden;flex:0 0 auto;display:grid;place-items:center;
|
| 201 |
+
background:linear-gradient(140deg,#262640,#151522);border:1px solid var(--line)
|
| 202 |
+
}
|
| 203 |
+
.np-art img{width:100%;height:100%;object-fit:cover;display:block}
|
| 204 |
+
.np-art svg{color:var(--dim)}
|
| 205 |
+
.np-meta{min-width:0}
|
| 206 |
+
.np-title{font-size:14px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 207 |
+
.np-artist{font-size:12.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:3px}
|
| 208 |
+
.np-artist a{color:inherit;text-decoration:none}
|
| 209 |
+
.np-artist a:hover{color:var(--accent-2)}
|
| 210 |
+
.in-queue{margin-left:auto}
|
| 211 |
+
|
| 212 |
+
.controls{display:flex;flex-direction:column;gap:6px;min-width:0}
|
| 213 |
+
.buttons{display:flex;align-items:center;justify-content:center;gap:8px}
|
| 214 |
+
.icon-btn{width:38px;height:38px;border-radius:50%;display:grid;place-items:center;color:var(--muted);transition:.15s}
|
| 215 |
+
.icon-btn:hover{color:var(--text);background:rgba(255,255,255,.08)}
|
| 216 |
+
.icon-btn.on{color:var(--accent-2);background:var(--accent-soft)}
|
| 217 |
+
.icon-btn svg{width:19px;height:19px}
|
| 218 |
+
.play-btn{
|
| 219 |
+
width:47px;height:47px;border-radius:50%;display:grid;place-items:center;color:#0b0b12;
|
| 220 |
+
background:linear-gradient(140deg,#a78bfa,#22d3ee);box-shadow:0 8px 22px -8px rgba(139,92,246,.9);transition:.15s
|
| 221 |
+
}
|
| 222 |
+
.play-btn:hover{transform:scale(1.05)}
|
| 223 |
+
.play-btn svg{width:20px;height:20px}
|
| 224 |
+
.seekrow{display:flex;align-items:center;gap:11px}
|
| 225 |
+
.seekrow .t{font-size:11.5px;color:var(--dim);font-variant-numeric:tabular-nums;min-width:36px;text-align:center}
|
| 226 |
+
.seekwrap{position:relative;flex:1;height:16px;display:flex;align-items:center}
|
| 227 |
+
.seekwrap input[type=range]{width:100%;height:16px;position:relative;z-index:2}
|
| 228 |
+
.seekwrap input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:99px;background:rgba(255,255,255,.13)}
|
| 229 |
+
.seekwrap input[type=range]::-moz-range-track{height:4px;border-radius:99px;background:rgba(255,255,255,.13)}
|
| 230 |
+
.seekwrap input[type=range]::-webkit-slider-thumb{appearance:none;width:12px;height:12px;border-radius:50%;background:#fff;margin-top:-4px;opacity:0;transition:opacity .15s}
|
| 231 |
+
.seekwrap input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radius:50%;background:#fff;opacity:0}
|
| 232 |
+
.seekwrap:hover input[type=range]::-webkit-slider-thumb,.seekwrap input[type=range]:focus::-webkit-slider-thumb{opacity:1}
|
| 233 |
+
.seekwrap:hover input[type=range]::-moz-range-thumb{opacity:1}
|
| 234 |
+
.seekfill{position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);height:4px;border-radius:99px;pointer-events:none;overflow:hidden}
|
| 235 |
+
.seekfill span{display:block;height:100%;width:0;border-radius:99px;background:linear-gradient(90deg,var(--accent),var(--accent-2))}
|
| 236 |
+
|
| 237 |
+
.right{display:flex;align-items:center;justify-content:flex-end;gap:10px}
|
| 238 |
+
.vol{width:92px;height:16px}
|
| 239 |
+
.vol::-webkit-slider-runnable-track{height:4px;border-radius:99px;background:rgba(255,255,255,.13)}
|
| 240 |
+
.vol::-moz-range-track{height:4px;border-radius:99px;background:rgba(255,255,255,.13)}
|
| 241 |
+
.vol::-webkit-slider-thumb{appearance:none;width:12px;height:12px;border-radius:50%;background:var(--accent-2);margin-top:-4px}
|
| 242 |
+
.vol::-moz-range-thumb{width:12px;height:12px;border:0;border-radius:50%;background:var(--accent-2)}
|
| 243 |
+
.badge{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;color:var(--accent-2);border:1px solid rgba(34,211,238,.35);background:rgba(34,211,238,.1);padding:4px 10px;border-radius:99px}
|
| 244 |
+
.badge svg{width:13px;height:13px}
|
| 245 |
+
|
| 246 |
+
.spinner{display:grid;place-items:center;width:30px;height:30px}
|
| 247 |
+
.spinner span{width:19px;height:19px;border-radius:50%;border:2px solid rgba(255,255,255,.18);border-top-color:var(--accent-2);animation:spin .8s linear infinite}
|
| 248 |
+
@keyframes spin{to{transform:rotate(360deg)}}
|
| 249 |
+
|
| 250 |
+
/* toasts */
|
| 251 |
+
.toasts{position:fixed;bottom:calc(var(--player-h) + 18px);right:22px;z-index:99;display:flex;flex-direction:column;gap:8px;align-items:flex-end;pointer-events:none;max-width:min(420px,calc(100vw - 44px))}
|
| 252 |
+
.toast{
|
| 253 |
+
background:rgba(26,26,38,.97);border:1px solid var(--line);color:var(--text);padding:11px 18px;border-radius:12px;
|
| 254 |
+
font-size:13px;line-height:1.55;box-shadow:var(--shadow);animation:pop .22s ease;max-width:100%;text-align:left
|
| 255 |
+
}
|
| 256 |
+
.toast.err{border-color:rgba(244,63,94,.45)}
|
| 257 |
+
@keyframes pop{from{opacity:0;transform:translateY(8px) scale(.97)}}
|
| 258 |
+
|
| 259 |
+
.empty{padding:60px 10px;text-align:center;color:var(--dim)}
|
| 260 |
+
.empty h3{color:var(--muted);margin:0 0 8px;font-weight:600}
|
| 261 |
+
.skeleton{height:60px;border-radius:11px;background:linear-gradient(90deg,rgba(255,255,255,.04),rgba(255,255,255,.09),rgba(255,255,255,.04));background-size:200% 100%;animation:sk 1.2s linear infinite;margin-bottom:6px}
|
| 262 |
+
@keyframes sk{to{background-position:-200% 0}}
|
| 263 |
+
|
| 264 |
+
@media (max-width:900px){
|
| 265 |
+
.content{grid-template-columns:minmax(0,1fr)}
|
| 266 |
+
.queue-panel{position:fixed;top:0;right:0;bottom:var(--player-h);width:min(88vw,340px);z-index:80;transform:translateX(102%);transition:transform .24s ease;box-shadow:var(--shadow)}
|
| 267 |
+
.queue-panel.open{transform:none}
|
| 268 |
+
.player{grid-template-columns:minmax(0,1fr) auto;grid-template-areas:"np right" "controls controls";
|
| 269 |
+
gap:6px 10px;padding:9px 14px;min-height:0}
|
| 270 |
+
.np{grid-area:np}.controls{grid-area:controls}.right{grid-area:right;gap:8px}
|
| 271 |
+
.controls{gap:10px}
|
| 272 |
+
.seekrow{margin-top:2px}
|
| 273 |
+
.vol{display:none}
|
| 274 |
+
.ctrl-add{display:none}
|
| 275 |
+
.view{padding:18px 14px 30px}
|
| 276 |
+
.trow{grid-template-columns:26px 42px minmax(0,1fr) auto;gap:10px}
|
| 277 |
+
.trow .album{display:none}
|
| 278 |
+
.searchwrap kbd{display:none}
|
| 279 |
+
.brand-name{display:none}
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
/* ═══════════════════ dashboard layer ═══════════════════ */
|
| 283 |
+
body{overflow:hidden}
|
| 284 |
+
.app{grid-template-rows:auto minmax(0,1fr) auto}
|
| 285 |
+
.brand-tag{
|
| 286 |
+
font-size:10.5px;letter-spacing:1.2px;text-transform:uppercase;color:var(--accent-2);
|
| 287 |
+
border:1px solid rgba(34,211,238,.32);background:rgba(34,211,238,.08);padding:3px 8px;border-radius:99px;font-weight:700
|
| 288 |
+
}
|
| 289 |
+
.topbar-right{display:flex;align-items:center;gap:10px;flex:0 0 auto}
|
| 290 |
+
.ghost-select{
|
| 291 |
+
appearance:none;-webkit-appearance:none;background:rgba(255,255,255,.03) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23969db4'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E") no-repeat right 8px center/16px;
|
| 292 |
+
border:1px solid var(--line);color:var(--muted);border-radius:999px;padding:9px 30px 9px 14px;font-size:13px;cursor:pointer;outline:none
|
| 293 |
+
}
|
| 294 |
+
.ghost-select:hover{color:var(--text);background-color:rgba(255,255,255,.08)}
|
| 295 |
+
.ghost-select option{background:#14141e;color:var(--text)}
|
| 296 |
+
|
| 297 |
+
/* three-panel body */
|
| 298 |
+
.body{display:grid;grid-template-columns:236px minmax(0,1fr) 348px;min-height:0;overflow:hidden}
|
| 299 |
+
.body.rail-hidden{grid-template-columns:236px minmax(0,1fr) 0px}
|
| 300 |
+
|
| 301 |
+
.sidebar{
|
| 302 |
+
border-right:1px solid var(--line);padding:18px 12px;display:flex;flex-direction:column;gap:3px;
|
| 303 |
+
background:rgba(13,13,20,.55);backdrop-filter:blur(18px);overflow-y:auto;min-height:0
|
| 304 |
+
}
|
| 305 |
+
.nav-label{font-size:10.5px;letter-spacing:1.3px;text-transform:uppercase;color:var(--dim);font-weight:700;padding:8px 12px 6px}
|
| 306 |
+
.nav-item{
|
| 307 |
+
display:flex;align-items:center;gap:11px;padding:10px 12px;border-radius:11px;color:var(--muted);
|
| 308 |
+
font-size:14px;font-weight:520;text-align:left;transition:.14s;width:100%
|
| 309 |
+
}
|
| 310 |
+
.nav-item svg{width:17px;height:17px;opacity:.85}
|
| 311 |
+
.nav-item:hover{background:var(--card-hover);color:var(--text)}
|
| 312 |
+
.nav-item.active{background:linear-gradient(96deg,rgba(139,92,246,.22),rgba(34,211,238,.10));color:var(--text);box-shadow:inset 0 0 0 1px rgba(139,92,246,.32)}
|
| 313 |
+
|
| 314 |
+
.engine{margin-top:auto;border:1px solid var(--line);border-radius:13px;background:var(--card);padding:12px;display:flex;flex-direction:column;gap:8px}
|
| 315 |
+
.engine-row{display:flex;justify-content:space-between;align-items:baseline;font-size:11.5px;color:var(--dim)}
|
| 316 |
+
.engine-row b{color:var(--text);font-variant-numeric:tabular-nums;font-weight:600;font-size:12px}
|
| 317 |
+
.engine-bar{height:4px;border-radius:99px;background:rgba(255,255,255,.1);overflow:hidden}
|
| 318 |
+
.engine-bar span{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--accent),var(--accent-2));transition:width .4s}
|
| 319 |
+
|
| 320 |
+
.main{overflow-y:auto;padding:22px 24px 34px;min-height:0}
|
| 321 |
+
.rail{overflow:hidden;border-left:1px solid var(--line);background:var(--panel);display:flex;flex-direction:column;min-height:0;backdrop-filter:blur(20px)}
|
| 322 |
+
.rail-body{overflow-y:auto;min-height:0;flex:1}
|
| 323 |
+
.rail-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding:20px 20px 14px;border-bottom:1px solid var(--line)}
|
| 324 |
+
.rail-head h2{font-size:15px;margin:0;font-weight:700}
|
| 325 |
+
.rail-head p{margin:4px 0 0;font-size:12px;color:var(--dim)}
|
| 326 |
+
|
| 327 |
+
/* stat tiles */
|
| 328 |
+
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px}
|
| 329 |
+
.tile{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:13px 15px;min-width:0;position:relative;overflow:hidden}
|
| 330 |
+
.tile::after{content:"";position:absolute;inset:0 auto 0 0;width:3px;background:linear-gradient(180deg,var(--accent),var(--accent-2));opacity:.75}
|
| 331 |
+
.tile .t-label{font-size:10.5px;letter-spacing:1.1px;text-transform:uppercase;color:var(--dim);font-weight:700;display:flex;align-items:center;gap:6px}
|
| 332 |
+
.tile .t-main{font-size:17px;font-weight:700;margin-top:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 333 |
+
.tile .t-sub{font-size:11.5px;color:var(--muted);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 334 |
+
.tile.ok .t-sub{color:var(--accent-2)}
|
| 335 |
+
.tile.warn .t-sub{color:#fbbf24}
|
| 336 |
+
.tile .t-spark{position:absolute;right:10px;bottom:8px;width:56px;height:22px;opacity:.55}
|
| 337 |
+
|
| 338 |
+
/* panels */
|
| 339 |
+
.grid2{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);gap:16px;margin-top:16px;align-items:start}
|
| 340 |
+
.panel{background:var(--card);border:1px solid var(--line);border-radius:16px;overflow:hidden}
|
| 341 |
+
.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border-bottom:1px solid var(--line)}
|
| 342 |
+
.panel-head h2{margin:0;font-size:13px;letter-spacing:.5px;text-transform:uppercase;color:var(--muted);font-weight:700;display:flex;align-items:center;gap:8px}
|
| 343 |
+
.panel-head h2 svg{width:15px;height:15px;color:var(--accent)}
|
| 344 |
+
.panel-body{padding:14px 16px}
|
| 345 |
+
.panel-body.tight{padding:8px}
|
| 346 |
+
.panel-foot{padding:11px 16px;border-top:1px solid var(--line);display:flex;justify-content:center}
|
| 347 |
+
.link-btn{font-size:12.5px;color:var(--accent-2);display:inline-flex;align-items:center;gap:7px;padding:7px 13px;border-radius:99px;border:1px solid rgba(34,211,238,.28);transition:.15s}
|
| 348 |
+
.link-btn:hover{background:rgba(34,211,238,.12);color:#fff}
|
| 349 |
+
.link-btn[disabled]{opacity:.5;cursor:default}
|
| 350 |
+
|
| 351 |
+
/* now playing hero */
|
| 352 |
+
.np-hero{display:flex;gap:18px;align-items:flex-start;padding:4px}
|
| 353 |
+
.np-hero .art{
|
| 354 |
+
width:178px;height:178px;border-radius:16px;flex:0 0 auto;background:linear-gradient(140deg,#262640,#151522);
|
| 355 |
+
overflow:hidden;box-shadow:var(--shadow);display:grid;place-items:center;border:1px solid var(--line)
|
| 356 |
+
}
|
| 357 |
+
.np-hero .art img{width:100%;height:100%;object-fit:cover;display:block}
|
| 358 |
+
.np-hero .art svg{width:44px;height:44px;color:var(--dim)}
|
| 359 |
+
.np-hero .info{min-width:0;flex:1}
|
| 360 |
+
.np-hero .badges{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:9px}
|
| 361 |
+
.np-hero h1{font-size:clamp(20px,2.6vw,29px);margin:0 0 8px;font-weight:800;letter-spacing:-.4px;line-height:1.14}
|
| 362 |
+
.np-hero .byline{font-size:14px;color:var(--muted);margin-bottom:4px}
|
| 363 |
+
.np-hero .byline a{color:var(--text);text-decoration:none;font-weight:600}
|
| 364 |
+
.np-hero .byline a:hover{color:var(--accent-2)}
|
| 365 |
+
.np-hero .facts{font-size:12.5px;color:var(--dim);margin-bottom:14px}
|
| 366 |
+
.np-hero .hero-btns{display:flex;gap:9px;flex-wrap:wrap}
|
| 367 |
+
|
| 368 |
+
.feed{display:flex;flex-direction:column;gap:2px}
|
| 369 |
+
.feed-row{display:grid;grid-template-columns:38px minmax(0,1fr) auto;gap:11px;align-items:center;padding:7px 9px;border-radius:10px;cursor:pointer;transition:background .12s}
|
| 370 |
+
.feed-row:hover{background:var(--card-hover)}
|
| 371 |
+
.feed-row.now{background:var(--accent-soft)}
|
| 372 |
+
.feed-row img{width:38px;height:38px;border-radius:8px;object-fit:cover;background:#1c1c28}
|
| 373 |
+
.feed-row .ft{font-size:13.5px;font-weight:550;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 374 |
+
.feed-row .fa{font-size:11.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 375 |
+
.reason{font-size:10.5px;color:var(--accent-2);background:rgba(34,211,238,.09);border:1px solid rgba(34,211,238,.2);padding:2px 8px;border-radius:99px;margin-left:7px;white-space:nowrap}
|
| 376 |
+
|
| 377 |
+
/* trending artist cards */
|
| 378 |
+
.artist-strip{display:grid;grid-template-columns:repeat(auto-fill,minmax(118px,1fr));gap:11px;padding:6px}
|
| 379 |
+
.artist-card{background:var(--card);border:1px solid var(--line);border-radius:13px;padding:11px;text-align:center;cursor:pointer;transition:.15s;width:100%}
|
| 380 |
+
.artist-card:hover{transform:translateY(-2px);background:var(--card-hover);border-color:rgba(139,92,246,.35)}
|
| 381 |
+
.artist-card img,.artist-card .ph{width:64px;height:64px;border-radius:50%;object-fit:cover;margin:0 auto 8px;display:grid;place-items:center;background:linear-gradient(140deg,#262640,#151522);border:1px solid var(--line)}
|
| 382 |
+
.artist-card .ph svg{width:26px;height:26px;color:var(--dim)}
|
| 383 |
+
.artist-card .ph-ini{font-size:20px;font-weight:700;letter-spacing:.5px;color:rgba(255,255,255,.82)}
|
| 384 |
+
.artist-card .n{font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 385 |
+
.artist-card .s{font-size:11px;color:var(--dim);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 386 |
+
.trend-up{color:#4ade80}.trend-down{color:#f87171}.trend-neutral{color:var(--dim)}
|
| 387 |
+
|
| 388 |
+
/* genre tiles */
|
| 389 |
+
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(168px,1fr));gap:12px}
|
| 390 |
+
.tile-btn{
|
| 391 |
+
position:relative;border-radius:15px;padding:16px;min-height:96px;text-align:left;overflow:hidden;
|
| 392 |
+
border:1px solid var(--line);background:linear-gradient(135deg,#1b1b2b,#12121c);color:var(--text);transition:.16s
|
| 393 |
+
}
|
| 394 |
+
.tile-btn:hover{transform:translateY(-2px);border-color:rgba(139,92,246,.45)}
|
| 395 |
+
.tile-btn .tb-title{font-size:15px;font-weight:700;position:relative;z-index:2;display:block;max-width:80%}
|
| 396 |
+
.tile-btn .tb-sub{font-size:11.5px;color:var(--muted);position:relative;z-index:2;margin-top:4px;display:block}
|
| 397 |
+
.tile-btn .tb-glow{position:absolute;width:110px;height:110px;border-radius:50%;right:-42px;bottom:-46px;filter:blur(26px);opacity:.75}
|
| 398 |
+
|
| 399 |
+
/* view headers */
|
| 400 |
+
.view-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:16px;flex-wrap:wrap}
|
| 401 |
+
.view-head h1{font-size:clamp(21px,2.6vw,29px);margin:0;font-weight:800;letter-spacing:-.4px}
|
| 402 |
+
.view-head .sub{color:var(--muted);font-size:13px;margin-top:5px}
|
| 403 |
+
.crumbs{font-size:12px;color:var(--dim);margin-bottom:12px;display:flex;gap:7px;align-items:center;flex-wrap:wrap}
|
| 404 |
+
.crumbs button{color:var(--accent-2);font-size:12px}
|
| 405 |
+
.crumbs button:hover{text-decoration:underline}
|
| 406 |
+
|
| 407 |
+
.empty-panel{padding:34px 18px;text-align:center;color:var(--dim);font-size:13px;line-height:1.7}
|
| 408 |
+
.kbd-hint{display:inline-block;border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:11px;color:var(--muted);background:rgba(255,255,255,.04)}
|
| 409 |
+
|
| 410 |
+
@media (max-width:1180px){
|
| 411 |
+
.ghost-btn span{display:none}
|
| 412 |
+
.ghost-btn{padding:0 11px}
|
| 413 |
+
.body{grid-template-columns:74px minmax(0,1fr) 348px}
|
| 414 |
+
.body.rail-hidden{grid-template-columns:74px minmax(0,1fr) 0px}
|
| 415 |
+
.nav-item span,.nav-item{font-size:0}
|
| 416 |
+
.nav-item{justify-content:center;padding:12px}
|
| 417 |
+
.nav-item svg{width:19px;height:19px}
|
| 418 |
+
.nav-label{text-align:center;font-size:0;padding:6px}
|
| 419 |
+
.nav-label::after{content:"•";font-size:12px}
|
| 420 |
+
.engine{display:none}
|
| 421 |
+
}
|
| 422 |
+
@media (max-width:900px){
|
| 423 |
+
.body{grid-template-columns:minmax(0,1fr)}
|
| 424 |
+
.sidebar{flex-direction:row;overflow-x:auto;border-right:0;border-bottom:1px solid var(--line);padding:8px;gap:6px}
|
| 425 |
+
.nav-item{width:auto;white-space:nowrap}
|
| 426 |
+
.nav-item{font-size:13px}
|
| 427 |
+
.nav-label{display:none}
|
| 428 |
+
.engine{display:none}
|
| 429 |
+
.rail{position:fixed;top:0;right:0;bottom:var(--player-h);width:min(88vw,340px);z-index:80;transform:translateX(102%);transition:transform .24s ease;box-shadow:var(--shadow)}
|
| 430 |
+
.rail.open{transform:none}
|
| 431 |
+
.grid2{grid-template-columns:minmax(0,1fr)}
|
| 432 |
+
.main{padding:16px 14px 28px}
|
| 433 |
+
.np-hero .art{width:118px;height:118px}
|
| 434 |
+
.topbar-right .ghost-btn span{display:none}
|
| 435 |
+
.brand-name,.brand-tag{display:none}
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
/* ════════════════════════════════════════════════════════════════════════
|
| 439 |
+
Phones (≤640px) — layout tuned for a one-handed, thumb-reachable app
|
| 440 |
+
════════════════════════════════════════════════════════════════════════ */
|
| 441 |
+
@media (max-width:640px){
|
| 442 |
+
:root{ --player-h:104px; }
|
| 443 |
+
body{ -webkit-tap-highlight-color:transparent; touch-action:manipulation; }
|
| 444 |
+
|
| 445 |
+
/* top bar: compact row of actions, search gets its own full-width row */
|
| 446 |
+
.topbar{flex-wrap:wrap;gap:8px;padding:9px 11px}
|
| 447 |
+
.brand{gap:0}
|
| 448 |
+
.searchwrap{order:3;flex:1 1 100%;max-width:none;margin:0}
|
| 449 |
+
.searchwrap .sicon{left:12px;width:17px;height:17px}
|
| 450 |
+
.searchwrap input{padding:10px 14px 10px 37px;font-size:16px;border-radius:12px}
|
| 451 |
+
.searchwrap kbd{display:none}
|
| 452 |
+
.suggest{max-height:48vh;overflow-y:auto}
|
| 453 |
+
.topbar-right{margin-left:auto;gap:8px}
|
| 454 |
+
.ghost-select{max-width:132px;height:34px;font-size:12px}
|
| 455 |
+
#modePill{height:34px;padding:0 10px;font-size:11px}
|
| 456 |
+
|
| 457 |
+
/* nav: icon strip, easy to thumb */
|
| 458 |
+
.sidebar{padding:5px;gap:3px}
|
| 459 |
+
.nav-item{padding:10px 13px;border-radius:10px}
|
| 460 |
+
.nav-item svg{width:19px;height:19px}
|
| 461 |
+
|
| 462 |
+
.main{padding:11px 11px 16px}
|
| 463 |
+
.view-head h1{font-size:20px}
|
| 464 |
+
.view-head .sub{font-size:12px}
|
| 465 |
+
|
| 466 |
+
/* stat tiles: 2×2 instead of four full-width cards */
|
| 467 |
+
.stats{grid-template-columns:1fr 1fr;gap:8px}
|
| 468 |
+
.tile{padding:9px 11px;border-radius:12px}
|
| 469 |
+
.tile::after{width:2px}
|
| 470 |
+
.tile .t-label{font-size:9px;letter-spacing:.6px;gap:4px}
|
| 471 |
+
.tile .t-label svg{width:11px;height:11px}
|
| 472 |
+
.tile .t-main{font-size:14px;margin-top:3px}
|
| 473 |
+
.tile .t-sub{font-size:10px;margin-top:1px}
|
| 474 |
+
.tile .t-spark{display:none}
|
| 475 |
+
|
| 476 |
+
/* now playing hero */
|
| 477 |
+
.np-hero{padding:14px;gap:12px;grid-template-columns:1fr}
|
| 478 |
+
.np-hero .art,.np-hero img{width:104px;height:104px;margin:0 auto;border-radius:14px}
|
| 479 |
+
.np-hero h1{font-size:19px;line-height:1.25}
|
| 480 |
+
.np-hero .hero-btns{gap:7px}
|
| 481 |
+
.np-hero .hero-btns .chip{font-size:12px;padding:8px 11px}
|
| 482 |
+
|
| 483 |
+
/* panels & rows */
|
| 484 |
+
.grid2{gap:11px;margin-top:11px}
|
| 485 |
+
.panel-head{padding:11px 12px 9px}
|
| 486 |
+
.panel-head h2{font-size:12.5px}
|
| 487 |
+
.panel-body{padding:11px 12px}
|
| 488 |
+
.panel-body.tight{padding:5px}
|
| 489 |
+
.trow{grid-template-columns:24px 40px minmax(0,1fr) auto;gap:9px;padding:6px 5px}
|
| 490 |
+
.trow .art{width:40px;height:40px;border-radius:7px}
|
| 491 |
+
.trow .album{display:none}
|
| 492 |
+
.trow .tt{font-size:13.5px}
|
| 493 |
+
.trow .ta{font-size:11.5px}
|
| 494 |
+
.trow .end .addq{display:none} /* += lives in the queue button instead */
|
| 495 |
+
.artist-strip{gap:8px;grid-auto-columns:96px;padding-bottom:4px}
|
| 496 |
+
.artist-card{padding:8px}
|
| 497 |
+
.artist-card img,.artist-card .ph{width:54px;height:54px}
|
| 498 |
+
.artist-card .n{font-size:11.5px}
|
| 499 |
+
.tiles{grid-template-columns:1fr 1fr;gap:8px}
|
| 500 |
+
.tile-btn{padding:12px;min-height:64px}
|
| 501 |
+
.tb-title{font-size:13px}
|
| 502 |
+
.chips{gap:6px}
|
| 503 |
+
.chip{font-size:12px;padding:7px 11px}
|
| 504 |
+
.crumbs{font-size:11.5px;margin-bottom:10px}
|
| 505 |
+
|
| 506 |
+
/* player bar: art + title + transport, then a thin seek line */
|
| 507 |
+
.player{padding:8px 11px calc(7px + env(safe-area-inset-bottom));
|
| 508 |
+
gap:2px 8px;min-height:0;grid-template-columns:minmax(0,1fr) auto;
|
| 509 |
+
grid-template-areas:"np right" "controls controls"}
|
| 510 |
+
.np{grid-area:np;gap:9px;min-width:0}
|
| 511 |
+
.np-art{width:42px;height:42px;border-radius:9px}
|
| 512 |
+
.np-title{font-size:13.5px}
|
| 513 |
+
.np-artist{font-size:11.5px}
|
| 514 |
+
.in-queue{display:none}
|
| 515 |
+
.controls{grid-area:controls;gap:2px}
|
| 516 |
+
.buttons{gap:12px}
|
| 517 |
+
.buttons .icon-btn{width:34px;height:34px}
|
| 518 |
+
.buttons .icon-btn svg{width:19px;height:19px}
|
| 519 |
+
.play-btn{width:44px;height:44px}
|
| 520 |
+
.play-btn svg{width:20px;height:20px}
|
| 521 |
+
#shuffleBtn,#repeatBtn{display:none} /* declutter; still on S / R keys */
|
| 522 |
+
.seekrow{gap:8px;margin-top:0}
|
| 523 |
+
.seekrow .t{font-size:10.5px;min-width:30px}
|
| 524 |
+
.right{grid-area:right;gap:6px}
|
| 525 |
+
.right .vol,.right #muteBtn{display:none} /* volume is the phone's own */
|
| 526 |
+
.badge{font-size:10px;padding:3px 7px}
|
| 527 |
+
|
| 528 |
+
/* queue rail: full-height sheet */
|
| 529 |
+
.rail{width:min(92vw,340px);bottom:var(--player-h)}
|
| 530 |
+
.qrow{grid-template-columns:34px minmax(0,1fr) auto;gap:8px;padding:6px}
|
| 531 |
+
.qrow img{width:34px;height:34px}
|
| 532 |
+
|
| 533 |
+
/* toasts clear the taller bottom bar */
|
| 534 |
+
.toasts{right:10px;left:10px;bottom:calc(var(--player-h) + 10px);max-width:none;align-items:stretch}
|
| 535 |
+
.toast{max-width:none;text-align:left}
|
| 536 |
+
|
| 537 |
+
.cfg-card{padding:18px;border-radius:16px}
|
| 538 |
+
.cfg-actions{flex-direction:column-reverse}
|
| 539 |
+
.cfg-btn{width:100%}
|
| 540 |
+
|
| 541 |
+
/* reasons on rows: keep them, but stop them dominating the row */
|
| 542 |
+
.feed-row{grid-template-columns:38px minmax(0,1fr) auto;gap:9px;padding:8px 5px}
|
| 543 |
+
.feed-row img{width:38px;height:38px}
|
| 544 |
+
.feed-row .ft{font-size:13px}
|
| 545 |
+
.feed-row .fa{font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
| 546 |
+
.reason{font-size:9.5px;padding:1px 5px;margin-left:4px;vertical-align:1px;max-width:46vw;
|
| 547 |
+
display:inline-block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
| 548 |
+
|
| 549 |
+
/* panel headers must stay on one line */
|
| 550 |
+
.panel-head{gap:8px}
|
| 551 |
+
.panel-head h2{font-size:11.5px;letter-spacing:.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 552 |
+
.panel-head .link-btn{flex:0 0 auto;font-size:11px;padding:6px 9px;white-space:nowrap}
|
| 553 |
+
.panel-head > span{display:none}
|
| 554 |
+
|
| 555 |
+
/* empty hero */
|
| 556 |
+
.np-hero .grid2{gap:10px}
|
| 557 |
+
|
| 558 |
+
.hero-tip{display:none}
|
| 559 |
+
.hero-tip-touch{display:block;font-size:12px;color:var(--muted);margin-top:2px}
|
| 560 |
+
/* hero keeps art beside the text instead of stacking a big square */
|
| 561 |
+
.np-hero{grid-template-columns:86px minmax(0,1fr);align-items:start;gap:11px}
|
| 562 |
+
.np-hero .art,.np-hero .art img{width:86px;height:86px;border-radius:12px;margin:0}
|
| 563 |
+
.np-hero .hero-btns{margin-top:8px;grid-column:1/-1}
|
| 564 |
+
.np-hero .badges{gap:5px;margin-bottom:5px}
|
| 565 |
+
.np-hero .badge{font-size:10px;padding:3px 7px}
|
| 566 |
+
.np-hero .facts{font-size:11.5px}
|
| 567 |
+
.np-hero .byline{font-size:12.5px}
|
| 568 |
+
}
|
| 569 |
+
/* touch devices never show keyboard hints */
|
| 570 |
+
@media (hover:none){ .hero-tip{display:none} .hero-tip-touch{display:block} .kbd-hint{display:none} .searchwrap kbd{display:none} }
|
static/dash.js
ADDED
|
@@ -0,0 +1,1092 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ── Cadence dashboard ─────────────────────────────────────────────────────
|
| 2 |
+
Views: overview · search · trending · browse (genres/moods) · artist · album
|
| 3 |
+
playlist · recent Engine stats + buffer-ahead telemetry.
|
| 4 |
+
──────────────────────────────────────────────────────────────────────── */
|
| 5 |
+
(() => {
|
| 6 |
+
'use strict';
|
| 7 |
+
|
| 8 |
+
const $ = (s, r = document) => r.querySelector(s);
|
| 9 |
+
const audio = $('#audio');
|
| 10 |
+
const main = $('#main');
|
| 11 |
+
|
| 12 |
+
/* ── state ──────────────────────────────────────────────────────────────── */
|
| 13 |
+
const state = {
|
| 14 |
+
queue: [], queueSource: null, index: -1, current: null,
|
| 15 |
+
shuffle: false, repeat: 0, queueLabel: null, station: null, growing: false,
|
| 16 |
+
suggestionsAdded: 0,
|
| 17 |
+
viewLists: {}, artistCache: {},
|
| 18 |
+
country: localStorage.getItem('cadence:country') || 'CH',
|
| 19 |
+
token: 0, buffered: { id: null, ready: false, bytes: 0 },
|
| 20 |
+
};
|
| 21 |
+
const LABELS = { search: 'Search results', top: 'Top songs', album: 'Album', recent: 'Recently played',
|
| 22 |
+
playlist: 'Playlist', trending: 'Trending', over_rec: 'Recently played', feed: 'Suggestions' };
|
| 23 |
+
|
| 24 |
+
const LS = {
|
| 25 |
+
get(k, d) { try { return JSON.parse(localStorage.getItem('cadence:' + k)) ?? d; } catch { return d; } },
|
| 26 |
+
set(k, v) { try { localStorage.setItem('cadence:' + k, JSON.stringify(v)); } catch {} },
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
/* ── helpers ────────────────────────────────────────────────────────────── */
|
| 30 |
+
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
| 31 |
+
const icon = n => `<svg aria-hidden="true"><use href="#i-${n}"/></svg>`;
|
| 32 |
+
const thumb = vid => `/api/thumb/${vid}`;
|
| 33 |
+
const imgp = u => `/api/img?u=${encodeURIComponent(u)}`;
|
| 34 |
+
const artistName = t => (t && (t.artist_text || 'Unknown artist')) || 'Unknown artist';
|
| 35 |
+
|
| 36 |
+
function fmtTime(sec) {
|
| 37 |
+
if (!sec || !isFinite(sec) || sec < 0) return '0:00';
|
| 38 |
+
sec = Math.floor(sec);
|
| 39 |
+
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
| 40 |
+
return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;
|
| 41 |
+
}
|
| 42 |
+
const fmtBytes = b => {
|
| 43 |
+
if (!b) return '0 B';
|
| 44 |
+
const u = ['B', 'KB', 'MB', 'GB']; const i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), 3);
|
| 45 |
+
return `${(b / 1024 ** i).toFixed(i ? 1 : 0)} ${u[i]}`;
|
| 46 |
+
};
|
| 47 |
+
const fmtDur = s => { s = Math.round(s || 0); const m = Math.floor(s / 60); return m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}m ${s % 60}s`; };
|
| 48 |
+
|
| 49 |
+
function toast(msg, isErr = false, ms = 3200) {
|
| 50 |
+
const el = document.createElement('div');
|
| 51 |
+
el.className = 'toast' + (isErr ? ' err' : '');
|
| 52 |
+
el.textContent = msg;
|
| 53 |
+
$('#toasts').appendChild(el);
|
| 54 |
+
setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; setTimeout(() => el.remove(), 320); }, ms);
|
| 55 |
+
}
|
| 56 |
+
async function api(path, opts) {
|
| 57 |
+
const r = await fetch(path, opts);
|
| 58 |
+
let data = null; try { data = await r.json(); } catch {}
|
| 59 |
+
if (!r.ok) {
|
| 60 |
+
const err = new Error((data && data.error) || `Request failed (${r.status})`);
|
| 61 |
+
err.status = r.status; err.code = data && data.code; err.hint = data && data.hint;
|
| 62 |
+
throw err;
|
| 63 |
+
}
|
| 64 |
+
return data;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// A dead <audio> element tells us nothing useful, so ask the server what went wrong.
|
| 68 |
+
let blockingReported = false;
|
| 69 |
+
async function diagnoseStream(id) {
|
| 70 |
+
try {
|
| 71 |
+
const r = await fetch(`/api/stream/${id}`, { headers: { Range: 'bytes=0-1' } });
|
| 72 |
+
if (r.ok) return { kind: 'transient' };
|
| 73 |
+
let d = {}; try { d = await r.json(); } catch {}
|
| 74 |
+
return { kind: d.code || 'unknown', message: d.error, hint: d.hint };
|
| 75 |
+
} catch {
|
| 76 |
+
return { kind: 'offline' };
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function reportBlocked(info) {
|
| 81 |
+
if (blockingReported) return;
|
| 82 |
+
blockingReported = true;
|
| 83 |
+
toast(`${info.message || 'Playback is blocked'} ${info.hint ? '— ' + info.hint : ''}`, true, 12000);
|
| 84 |
+
const badge = $('#radioBadge');
|
| 85 |
+
if (badge) { badge.hidden = false; badge.textContent = 'streaming blocked'; }
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const artistHref = a => `#/artist/${a.id ? encodeURIComponent(a.id) : ''}?name=${encodeURIComponent(a.name || '')}`;
|
| 89 |
+
|
| 90 |
+
// On touch screens a tap on a track row gets "adjusted" by the browser to the nearest
|
| 91 |
+
// link — so the artist name inside a row would hijack taps meant to play the song.
|
| 92 |
+
// Rows stay plain text on touch; artists remain reachable via cards and the hero buttons.
|
| 93 |
+
const COARSE_POINTER = !!(window.matchMedia && window.matchMedia('(hover: none)').matches);
|
| 94 |
+
|
| 95 |
+
function artistLink(a) {
|
| 96 |
+
if (COARSE_POINTER) return esc(a.name);
|
| 97 |
+
// real hash link: shareable + right-clickable, and works even when the API gives no browse id
|
| 98 |
+
return `<a href="${artistHref(a)}" data-artist data-artist-id="${esc(a.id || '')}" data-artist-name="${esc(a.name)}">${esc(a.name)}</a>`;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/* ── shared renderers ───────────────────────────────────────────────────── */
|
| 102 |
+
function trackRows(tracks, listName, opts = {}) {
|
| 103 |
+
if (!tracks || !tracks.length) return `<div class="empty-panel">No tracks here.</div>`;
|
| 104 |
+
const showAlbum = opts.showAlbum !== false;
|
| 105 |
+
return `<div class="tracks${opts.showAlbum === false ? ' compact' : ''}">` + tracks.map((t, i) => `
|
| 106 |
+
<div class="trow" data-vid="${esc(t.video_id)}" data-list="${esc(listName)}" data-i="${i}">
|
| 107 |
+
<div class="idx"><span class="num" data-n="${opts.startNum ? opts.startNum + i : i + 1}">${opts.startNum ? opts.startNum + i : i + 1}</span>
|
| 108 |
+
<span class="playicon">${icon('play')}</span></div>
|
| 109 |
+
<img class="art" src="${thumb(t.video_id)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">
|
| 110 |
+
<div class="meta">
|
| 111 |
+
<div class="tt">${esc(t.title)}</div>
|
| 112 |
+
<div class="ta">${(t.artists || []).map(artistLink).join(', ') || esc(artistName(t))}</div>
|
| 113 |
+
</div>
|
| 114 |
+
${showAlbum ? `<div class="album">${t.album && t.album.id ? `<a data-album="${esc(t.album.id)}" href="#/album/${encodeURIComponent(t.album.id)}">${esc(t.album.name || '')}</a>` : esc((t.album && t.album.name) || '')}</div>` : '<div class="album"></div>'}
|
| 115 |
+
<div class="end">
|
| 116 |
+
<button class="icon-btn addq" data-action="similar" title="Play similar songs">${icon('spark')}</button>
|
| 117 |
+
<button class="icon-btn addq" data-action="queue" title="Add to queue">${icon('plus')}</button>
|
| 118 |
+
<span>${esc(fmtTime(t.duration))}</span>
|
| 119 |
+
</div>
|
| 120 |
+
</div>`).join('') + `</div>`;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function skeletons(n = 8) { return Array.from({ length: n }, () => `<div class="skeleton"></div>`).join(''); }
|
| 124 |
+
|
| 125 |
+
const initials = name => (name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase();
|
| 126 |
+
|
| 127 |
+
function artistCard(a) {
|
| 128 |
+
const src = a.thumbnail ? imgp(a.thumbnail) : '';
|
| 129 |
+
const pic = src
|
| 130 |
+
? `<img src="${src}" alt="" loading="lazy" onerror="this.classList.add('is-broken');this.style.display='none';this.nextElementSibling.style.display='grid'"><div class="ph ph-ini" style="display:none">${esc(initials(a.name))}</div>`
|
| 131 |
+
: `<div class="ph ph-ini">${esc(initials(a.name))}</div>`;
|
| 132 |
+
const trend = a.trend === 'up' ? ' <span class="trend-up">▲</span>' : a.trend === 'down' ? ' <span class="trend-down">▼</span>' : '';
|
| 133 |
+
return `<button class="artist-card" data-artist data-artist-id="${esc(a.id || '')}" data-artist-name="${esc(a.name)}">
|
| 134 |
+
${pic}
|
| 135 |
+
<div class="n">${esc(a.name)}</div>
|
| 136 |
+
<div class="s">${a.rank ? `#${a.rank} · ` : ''}${esc(a.subscribers || 'Artist')}${trend}</div>
|
| 137 |
+
</button>`;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
function markNowPlaying() {
|
| 141 |
+
const vid = state.current && state.current.video_id;
|
| 142 |
+
document.querySelectorAll('.trow, .qrow, .feed-row').forEach(el => {
|
| 143 |
+
const on = vid && el.dataset.vid === vid;
|
| 144 |
+
el.classList.toggle('now', !!on);
|
| 145 |
+
const num = el.querySelector('.idx .num');
|
| 146 |
+
if (num) num.innerHTML = on ? `<span class="nowbars"><i></i><i></i><i></i></span>` : (num.dataset.n || '');
|
| 147 |
+
});
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/* ── router ─────────────────────────────────────────────────────────────── */
|
| 151 |
+
const routes = {
|
| 152 |
+
overview: () => renderOverview(),
|
| 153 |
+
search: q => renderSearchView(q.get('q') || ''),
|
| 154 |
+
trending: () => renderTrendingView(),
|
| 155 |
+
browse: () => renderBrowse(),
|
| 156 |
+
genre: (q, path) => renderGenre(path, q.get('title') || ''),
|
| 157 |
+
playlist: (q, path) => renderPlaylist(path, q.get('title') || ''),
|
| 158 |
+
album: (q, path) => renderAlbum(path),
|
| 159 |
+
artist: (q, path) => renderArtistView(path, q.get('name') || ''),
|
| 160 |
+
recent: () => renderRecentView(),
|
| 161 |
+
};
|
| 162 |
+
|
| 163 |
+
function go(route) { location.hash = '#' + route; }
|
| 164 |
+
function currentRoute() { const h = location.hash.replace(/^#\/?/, '') || 'overview'; return '/' + h; }
|
| 165 |
+
|
| 166 |
+
function renderRoute() {
|
| 167 |
+
const raw = location.hash.replace(/^#\/?/, '') || 'overview';
|
| 168 |
+
const [pathPart, queryPart] = raw.split('?');
|
| 169 |
+
const segs = pathPart.split('/').filter(Boolean);
|
| 170 |
+
const name = segs[0] || 'overview';
|
| 171 |
+
const arg = segs[1] ? decodeURIComponent(segs.slice(1).join('/')) : '';
|
| 172 |
+
const q = new URLSearchParams(queryPart || '');
|
| 173 |
+
const fn = routes[name] || routes.overview;
|
| 174 |
+
document.querySelectorAll('.nav-item').forEach(b => b.classList.toggle('active', b.dataset.nav === name));
|
| 175 |
+
main.scrollTop = 0;
|
| 176 |
+
fn(q, arg);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
/* ── overview ───────────────────────────────────────────────────────────── */
|
| 180 |
+
async function renderOverview() {
|
| 181 |
+
const token = ++state.token;
|
| 182 |
+
const recent = LS.get('recent', []).slice(0, 6);
|
| 183 |
+
|
| 184 |
+
main.innerHTML = `
|
| 185 |
+
<section class="stats" id="statTiles">
|
| 186 |
+
${statNowPlaying()} ${statQueue()} ${statBuffer()} ${statRadio()}
|
| 187 |
+
</section>
|
| 188 |
+
|
| 189 |
+
<div class="grid2">
|
| 190 |
+
<section class="panel" id="npPanel">
|
| 191 |
+
<div class="panel-head"><h2>${icon('play')}Now playing</h2><span class="badge" id="npBadge" hidden>${icon('spark')}Radio</span></div>
|
| 192 |
+
<div class="panel-body" id="npBody">${nowPlayingHero()}</div>
|
| 193 |
+
</section>
|
| 194 |
+
|
| 195 |
+
<section class="panel">
|
| 196 |
+
<div class="panel-head">
|
| 197 |
+
<h2>${icon('spark')}Suggestion feed</h2>
|
| 198 |
+
<span style="font-size:11.5px;color:var(--dim)" id="feedCount"></span>
|
| 199 |
+
</div>
|
| 200 |
+
<div class="panel-body tight" id="feedBody"></div>
|
| 201 |
+
<div class="panel-foot">
|
| 202 |
+
<button class="link-btn" id="moreSuggestions">${icon('spark')}Load more suggestions</button>
|
| 203 |
+
</div>
|
| 204 |
+
</section>
|
| 205 |
+
</div>
|
| 206 |
+
|
| 207 |
+
<div class="grid2">
|
| 208 |
+
<section class="panel">
|
| 209 |
+
<div class="panel-head">
|
| 210 |
+
<h2>${icon('play')}Trending in <span id="trendRegion">…</span></h2>
|
| 211 |
+
<button class="link-btn" data-nav-to="trending">Open charts</button>
|
| 212 |
+
</div>
|
| 213 |
+
<div class="panel-body tight" id="trendMini">${skeletons(5)}</div>
|
| 214 |
+
</section>
|
| 215 |
+
|
| 216 |
+
<section class="panel">
|
| 217 |
+
<div class="panel-head"><h2>${icon('artist')}Trending artists</h2></div>
|
| 218 |
+
<div class="artist-strip" id="artistMini">${skeletons(4)}</div>
|
| 219 |
+
</section>
|
| 220 |
+
</div>
|
| 221 |
+
|
| 222 |
+
${recent.length ? `<section class="panel" style="margin-top:16px">
|
| 223 |
+
<div class="panel-head"><h2>${icon('queue')}Recently played</h2>
|
| 224 |
+
<button class="link-btn" data-nav-to="recent">See all</button></div>
|
| 225 |
+
<div class="panel-body tight">${trackRows(recent, 'over_rec', { showAlbum: false })}</div>
|
| 226 |
+
</section>` : ''}`;
|
| 227 |
+
|
| 228 |
+
state.viewLists.over_rec = recent;
|
| 229 |
+
paintFeed();
|
| 230 |
+
paintNowPlaying();
|
| 231 |
+
$('#moreSuggestions').addEventListener('click', async e => {
|
| 232 |
+
e.currentTarget.disabled = true;
|
| 233 |
+
const grew = await growQueue(true);
|
| 234 |
+
e.currentTarget.disabled = false;
|
| 235 |
+
if (!grew) toast('No more suggestions for this track', true);
|
| 236 |
+
});
|
| 237 |
+
main.querySelectorAll('[data-nav-to]').forEach(b => b.addEventListener('click', () => go('/' + b.dataset.navTo)));
|
| 238 |
+
|
| 239 |
+
// trending panels
|
| 240 |
+
try {
|
| 241 |
+
const data = await api(`/api/trending?country=${state.country}&limit=12`);
|
| 242 |
+
if (token !== state.token) return;
|
| 243 |
+
fillCountrySelect(data.countries);
|
| 244 |
+
const region = $('#trendRegion'); if (region) region.textContent = data.country_name;
|
| 245 |
+
const mini = $('#trendMini');
|
| 246 |
+
if (mini) {
|
| 247 |
+
state.viewLists.trending = data.tracks;
|
| 248 |
+
mini.innerHTML = trackRows(data.tracks.slice(0, 5), 'trending', { startNum: 1, showAlbum: false });
|
| 249 |
+
}
|
| 250 |
+
const strip = $('#artistMini');
|
| 251 |
+
if (strip) strip.innerHTML = data.artists.slice(0, 8).map(artistCard).join('') || '<div class="empty-panel">No chart data</div>';
|
| 252 |
+
markNowPlaying();
|
| 253 |
+
} catch (e) {
|
| 254 |
+
const mini = $('#trendMini'); if (mini) mini.innerHTML = `<div class="empty-panel">Charts unavailable — ${esc(e.message)}</div>`;
|
| 255 |
+
const strip = $('#artistMini'); if (strip) strip.innerHTML = '';
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
function nowPlayingHero() {
|
| 260 |
+
const t = state.current;
|
| 261 |
+
if (!t) return `<div class="np-hero"><div class="art">${icon('artist')}</div>
|
| 262 |
+
<div class="info"><h1 style="color:var(--muted)">Nothing playing</h1>
|
| 263 |
+
<div class="byline">Search for a song, or start a station from the panels below.</div>
|
| 264 |
+
<div class="facts hero-tip">Tip: press <span class="kbd-hint">/</span> to jump to search, <span class="kbd-hint">space</span> to play/pause.</div>
|
| 265 |
+
<div class="facts hero-tip-touch">Tap a row to play, or open Trending and Genres below.</div>
|
| 266 |
+
<div class="hero-btns">
|
| 267 |
+
<button class="chip strong" data-action="surprise">${icon('spark')} Surprise me</button>
|
| 268 |
+
</div></div></div>`;
|
| 269 |
+
return `<div class="np-hero" id="heroBox">
|
| 270 |
+
<div class="art"><img src="${thumb(t.video_id)}" alt=""></div>
|
| 271 |
+
<div class="info">
|
| 272 |
+
<div class="badges">
|
| 273 |
+
<span class="badge">${icon('play')}<span id="heroState">Loading…</span></span>
|
| 274 |
+
${state.station ? `<span class="badge">${icon('spark')}${esc(state.station)}</span>` : ''}
|
| 275 |
+
</div>
|
| 276 |
+
<h1>${esc(t.title)}</h1>
|
| 277 |
+
<div class="byline">${(t.artists || []).map(artistLink).join(', ') || esc(artistName(t))}</div>
|
| 278 |
+
<div class="facts" id="heroFacts">${esc(t.album && t.album.name || '')}${t.duration ? ` · ${fmtTime(t.duration)}` : ''}</div>
|
| 279 |
+
<div class="hero-btns">
|
| 280 |
+
<button class="chip strong" data-action="playpause">${icon('play')} <span id="heroBtnLabel">Play</span></button>
|
| 281 |
+
<button class="chip" data-action="similar-now">${icon('spark')} Play similar songs</button>
|
| 282 |
+
${firstArtistId(t) ? `<button class="chip" data-action="artist-radio" data-artist-id="${esc(firstArtistId(t))}" data-artist-name="${esc(artistName(t))}">${icon('spark')} Artist radio</button>` : ''}
|
| 283 |
+
${firstArtistId(t) ? `<button class="chip" data-artist data-artist-id="${esc(firstArtistId(t))}" data-artist-name="${esc(artistName(t))}">${icon('artist')} Open artist</button>` : ''}
|
| 284 |
+
</div>
|
| 285 |
+
</div></div>`;
|
| 286 |
+
}
|
| 287 |
+
function firstArtistId(t) { return ((t && t.artists) || []).find(a => a.id)?.id || null; }
|
| 288 |
+
|
| 289 |
+
/* stat tiles */
|
| 290 |
+
function statNowPlaying() {
|
| 291 |
+
const t = state.current;
|
| 292 |
+
return `<div class="tile" id="tileNow"><div class="t-label">${icon('play')}Now playing</div>
|
| 293 |
+
<div class="t-main" id="tnTitle">${t ? esc(t.title) : '—'}</div>
|
| 294 |
+
<div class="t-sub" id="tnSub">${t ? esc(artistName(t)) : 'nothing loaded'}</div></div>`;
|
| 295 |
+
}
|
| 296 |
+
function statQueue() {
|
| 297 |
+
return `<div class="tile" id="tileQueue"><div class="t-label">${icon('queue')}Queue</div>
|
| 298 |
+
<div class="t-main" id="tqMain">${state.queue.length} tracks</div>
|
| 299 |
+
<div class="t-sub" id="tqSub">${state.current ? `track ${state.index + 1} of ${state.queue.length}` : 'idle'}</div></div>`;
|
| 300 |
+
}
|
| 301 |
+
function statBuffer() {
|
| 302 |
+
return `<div class="tile" id="tileBuf"><div class="t-label">${icon('play')}Buffered ahead</div>
|
| 303 |
+
<div class="t-main" id="tbMain">–</div>
|
| 304 |
+
<div class="t-sub" id="tbSub">waiting for playback</div></div>`;
|
| 305 |
+
}
|
| 306 |
+
function statRadio() {
|
| 307 |
+
return `<div class="tile" id="tileRadio"><div class="t-label">${icon('spark')}Radio engine</div>
|
| 308 |
+
<div class="t-main" id="trMain">${state.suggestionsAdded} suggested</div>
|
| 309 |
+
<div class="t-sub" id="trSub">${state.radioLabel ? esc(state.radioLabel) : 'no station yet'}</div></div>`;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
function paintNowPlaying() {
|
| 313 |
+
const token = state.token;
|
| 314 |
+
const body = $('#npBody');
|
| 315 |
+
if (body) body.innerHTML = nowPlayingHero();
|
| 316 |
+
const tn = $('#tnTitle'), ts = $('#tnSub');
|
| 317 |
+
if (tn) tn.textContent = state.current ? state.current.title : '—';
|
| 318 |
+
if (ts) ts.textContent = state.current ? artistName(state.current) : 'nothing loaded';
|
| 319 |
+
updateHeroState();
|
| 320 |
+
paintStats();
|
| 321 |
+
void token;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
function updateHeroState() {
|
| 325 |
+
const el = $('#heroState'), label = $('#heroBtnLabel');
|
| 326 |
+
if (el) el.textContent = state.current ? (audio.paused ? 'Paused' : 'Playing') : '';
|
| 327 |
+
if (label) label.textContent = audio.paused ? 'Play' : 'Pause';
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
function paintStats() {
|
| 331 |
+
const q = $('#tqMain'); if (q) { q.textContent = `${state.queue.length} tracks`; $('#tqSub').textContent = state.current ? `track ${state.index + 1} of ${state.queue.length}` : 'idle'; }
|
| 332 |
+
const r = $('#trMain'); if (r) { r.textContent = `${state.suggestionsAdded} suggested`; $('#trSub').textContent = state.station || state.queueLabel || 'no station yet'; }
|
| 333 |
+
const badge = $('#npBadge');
|
| 334 |
+
if (badge) badge.hidden = !state.station;
|
| 335 |
+
const tb = $('#tbMain'), tbs = $('#tbSub');
|
| 336 |
+
if (tb && tbs) {
|
| 337 |
+
if (!state.buffered.id) { tb.textContent = '–'; tbs.textContent = 'waiting for playback'; }
|
| 338 |
+
else if (state.buffered.ready) { tb.textContent = 'Ready ✓'; tbs.textContent = `${fmtBytes(state.buffered.bytes)} cached for the next track`; $('.tile')?.classList.add('ok'); $('#tileBuf')?.classList.add('ok'); }
|
| 339 |
+
else { tb.textContent = 'Buffering…'; tbs.textContent = 'next track is being fetched'; $('#tileBuf')?.classList.remove('ok'); }
|
| 340 |
+
}
|
| 341 |
+
const heroFacts = $('#heroFacts');
|
| 342 |
+
if (heroFacts && state.current) {
|
| 343 |
+
const d = audio.duration || state.current.duration || 0;
|
| 344 |
+
heroFacts.textContent = [state.current.album && state.current.album.name, d ? fmtTime(d) : '', audio.currentTime ? `${fmtTime(audio.currentTime)} elapsed` : '']
|
| 345 |
+
.filter(Boolean).join(' · ');
|
| 346 |
+
}
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
/* suggestion feed */
|
| 350 |
+
function paintFeed() {
|
| 351 |
+
const body = $('#feedBody'); if (!body) return;
|
| 352 |
+
const upcoming = state.queue.slice(state.index + 1, state.index + 8);
|
| 353 |
+
const count = $('#feedCount');
|
| 354 |
+
if (count) count.textContent = state.queue.length > state.index + 1 ? `${state.queue.length - state.index - 1} in queue` : '';
|
| 355 |
+
if (!upcoming.length) {
|
| 356 |
+
body.innerHTML = `<div class="empty-panel">${state.current
|
| 357 |
+
? 'Queue is running low — suggestions will appear in a moment.'
|
| 358 |
+
: 'Play something and Cadence will suggest what comes next.'}</div>`;
|
| 359 |
+
return;
|
| 360 |
+
}
|
| 361 |
+
const curArtist = state.current ? artistName(state.current) : '';
|
| 362 |
+
body.innerHTML = `<div class="feed">` + upcoming.map((t, i) => {
|
| 363 |
+
const sameArtist = state.current && artistName(t) === curArtist;
|
| 364 |
+
const reason = i === 0 ? 'Up next' : sameArtist ? `More from ${esc(artistName(t))}` : `Similar to ${esc(curArtist)}`;
|
| 365 |
+
const qi = state.index + 1 + i;
|
| 366 |
+
return `<div class="feed-row" data-vid="${esc(t.video_id)}" data-qi="${qi}">
|
| 367 |
+
<img src="${thumb(t.video_id)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">
|
| 368 |
+
<div style="min-width:0">
|
| 369 |
+
<div class="ft">${esc(t.title)}</div>
|
| 370 |
+
<div class="fa">${esc(artistName(t))}<span class="reason">${reason}</span></div>
|
| 371 |
+
</div>
|
| 372 |
+
<span style="font-size:11.5px;color:var(--dim)">${esc(fmtTime(t.duration))}</span>
|
| 373 |
+
</div>`;
|
| 374 |
+
}).join('') + `</div>`;
|
| 375 |
+
markNowPlaying();
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
/* ── search ────────────────────────────────────────────���────────────────── */
|
| 379 |
+
async function renderSearchView(query) {
|
| 380 |
+
const token = ++state.token;
|
| 381 |
+
if (!query) {
|
| 382 |
+
main.innerHTML = `<div class="view-head"><div><h1>Search</h1><div class="sub">Find any song, artist, album or playlist on YouTube Music.</div></div></div>
|
| 383 |
+
<section class="panel"><div class="panel-body">
|
| 384 |
+
<div class="chips">${['Queen', 'Daft Punk', 'Drake', 'Lo-fi beats', 'Nina Simone', 'Coldplay', 'Beyoncé', 'Pink Floyd', 'Bad Bunny', 'Jazz classics']
|
| 385 |
+
.map(s => `<button class="chip" data-search="${esc(s)}">${esc(s)}</button>`).join('')}</div>
|
| 386 |
+
</div></section>`;
|
| 387 |
+
return;
|
| 388 |
+
}
|
| 389 |
+
main.innerHTML = `<div class="view-head"><div><h1>“${esc(query)}”</h1><div class="sub">Searching YouTube Music…</div></div></div>${skeletons(8)}`;
|
| 390 |
+
try {
|
| 391 |
+
const data = await api(`/api/search?q=${encodeURIComponent(query)}&limit=24`);
|
| 392 |
+
if (token !== state.token) return;
|
| 393 |
+
const tracks = data.tracks || [], artists = data.artists || [];
|
| 394 |
+
|
| 395 |
+
// Nothing at all: say so plainly, and offer a way forward instead of an empty page
|
| 396 |
+
if (!tracks.length && !artists.length) {
|
| 397 |
+
const chips = (data.suggestions && data.suggestions.length)
|
| 398 |
+
? data.suggestions
|
| 399 |
+
: ['daft punk', 'taylor swift', 'jazz classics', 'lo-fi beats'];
|
| 400 |
+
main.innerHTML = `
|
| 401 |
+
<div class="view-head"><div><h1>“${esc(query)}”</h1><div class="sub">No matches</div></div></div>
|
| 402 |
+
<section class="panel"><div class="panel-body">
|
| 403 |
+
<div class="search-empty">
|
| 404 |
+
<div class="se-icon">${icon('search')}</div>
|
| 405 |
+
<h2>Nothing found for “${esc(query)}”</h2>
|
| 406 |
+
<p>${window.__SRC__ && window.__SRC__.mode === 'demo'
|
| 407 |
+
? 'This file is running in demo mode, so it can only search its built-in library. Connect a backend to search all of YouTube Music.'
|
| 408 |
+
: 'Try a different spelling, or one of these searches:'}</p>
|
| 409 |
+
<div class="chips" style="justify-content:center">${chips.map(c => `<button class="chip" data-search="${esc(c)}">${esc(c)}</button>`).join('')}</div>
|
| 410 |
+
${window.__SRC__ && window.__SRC__.mode === 'demo'
|
| 411 |
+
? '<button class="link-btn" style="margin-top:14px" data-action="open-settings">Connect a backend →</button>' : ''}
|
| 412 |
+
</div>
|
| 413 |
+
</div></section>`;
|
| 414 |
+
return;
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
state.viewLists.search = tracks;
|
| 418 |
+
const count = `${tracks.length} ${tracks.length === 1 ? 'song' : 'songs'}${artists.length ? ` · ${artists.length} ${artists.length === 1 ? 'artist' : 'artists'}` : ''}`;
|
| 419 |
+
main.innerHTML = `
|
| 420 |
+
<div class="view-head"><div><h1>“${esc(query)}”</h1><div class="sub">${count}</div></div></div>
|
| 421 |
+
${artists.length ? `<section class="panel" style="margin-bottom:16px">
|
| 422 |
+
<div class="panel-head"><h2>${icon('artist')}Artists</h2>
|
| 423 |
+
<span style="font-size:11.5px;color:var(--dim)">${artists.length}</span></div>
|
| 424 |
+
<div class="artist-strip">${artists.slice(0, 10).map(artistCard).join('')}</div></section>` : ''}
|
| 425 |
+
<section class="panel">
|
| 426 |
+
<div class="panel-head"><h2>${icon('play')}Songs</h2>
|
| 427 |
+
${tracks.length ? `<button class="link-btn" data-action="play-list" data-list="search">${icon('play')}Play all</button>` : ''}</div>
|
| 428 |
+
<div class="panel-body tight">${tracks.length ? trackRows(tracks, 'search') : '<div class="empty-panel">No songs matched — try the artists above.</div>'}</div>
|
| 429 |
+
</section>`;
|
| 430 |
+
markNowPlaying();
|
| 431 |
+
} catch (e) {
|
| 432 |
+
if (token !== state.token) return;
|
| 433 |
+
main.innerHTML = `<div class="view-head"><div><h1>“${esc(query)}”</h1><div class="sub">Search failed</div></div></div>
|
| 434 |
+
<section class="panel"><div class="panel-body"><div class="search-empty">
|
| 435 |
+
<div class="se-icon">${icon('search')}</div>
|
| 436 |
+
<h2>Couldn't run that search</h2>
|
| 437 |
+
<p>${esc(e.message)}</p>
|
| 438 |
+
<button class="chip strong" data-search="${esc(query)}">Try again</button>
|
| 439 |
+
</div></div></section>`;
|
| 440 |
+
}
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
/* ── trending ───────────────────────────────────────────────────────────── */
|
| 444 |
+
async function renderTrendingView() {
|
| 445 |
+
const token = ++state.token;
|
| 446 |
+
main.innerHTML = `<div class="view-head"><div><h1>Trending</h1><div class="sub">YouTube Music charts</div></div></div>${skeletons(8)}`;
|
| 447 |
+
try {
|
| 448 |
+
const data = await api(`/api/trending?country=${state.country}&limit=24`);
|
| 449 |
+
if (token !== state.token) return;
|
| 450 |
+
fillCountrySelect(data.countries);
|
| 451 |
+
state.viewLists.trending = data.tracks;
|
| 452 |
+
main.innerHTML = `
|
| 453 |
+
<div class="view-head">
|
| 454 |
+
<div><h1>Trending in ${esc(data.country_name)}</h1><div class="sub">Updated from the YouTube Music charts</div></div>
|
| 455 |
+
<button class="link-btn" data-action="play-list" data-list="trending">${icon('play')}Play the chart</button>
|
| 456 |
+
</div>
|
| 457 |
+
<section class="panel" style="margin-bottom:16px">
|
| 458 |
+
<div class="panel-head"><h2>${icon('play')}Charting tracks</h2><span style="font-size:11.5px;color:var(--dim)">${data.tracks.length} tracks</span></div>
|
| 459 |
+
<div class="panel-body tight">${trackRows(data.tracks, 'trending')}</div>
|
| 460 |
+
</section>
|
| 461 |
+
<section class="panel">
|
| 462 |
+
<div class="panel-head"><h2>${icon('artist')}Trending artists</h2></div>
|
| 463 |
+
<div class="artist-strip">${data.artists.map(artistCard).join('')}</div>
|
| 464 |
+
</section>`;
|
| 465 |
+
markNowPlaying();
|
| 466 |
+
} catch (e) {
|
| 467 |
+
if (token !== state.token) return;
|
| 468 |
+
main.innerHTML = `<div class="empty-panel">Charts unavailable — ${esc(e.message)}</div>`;
|
| 469 |
+
}
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
function fillCountrySelect(options) {
|
| 473 |
+
const sel = $('#countrySel');
|
| 474 |
+
if (!sel || !options || !options.length) return;
|
| 475 |
+
const names = { ZZ: '🌍 Global', CH: '🇨🇭 Switzerland', US: '🇺🇸 United States', GB: '🇬🇧 United Kingdom',
|
| 476 |
+
DE: '🇩🇪 Germany', FR: '🇫🇷 France', IT: '🇮🇹 Italy', ES: '🇪🇸 Spain', AT: '🇦🇹 Austria',
|
| 477 |
+
NL: '🇳🇱 Netherlands', SE: '🇸🇪 Sweden', JP: '🇯🇵 Japan', KR: '🇰🇷 South Korea', BR: '🇧🇷 Brazil',
|
| 478 |
+
CA: '🇨🇦 Canada', AU: '🇦🇺 Australia', IN: '🇮🇳 India', MX: '🇲🇽 Mexico', PL: '🇵🇱 Poland', PT: '🇵🇹 Portugal' };
|
| 479 |
+
if (sel.dataset.filled === options.join(',')) return;
|
| 480 |
+
sel.innerHTML = options.map(c => `<option value="${esc(c)}" ${c === state.country ? 'selected' : ''}>${esc(names[c] || c)}</option>`).join('');
|
| 481 |
+
sel.dataset.filled = options.join(',');
|
| 482 |
+
sel.value = state.country;
|
| 483 |
+
}
|
| 484 |
+
$('#countrySel').addEventListener('change', e => {
|
| 485 |
+
state.country = e.target.value;
|
| 486 |
+
localStorage.setItem('cadence:country', state.country);
|
| 487 |
+
if ((currentRoute().includes('trending')) || currentRoute() === '/overview') renderRoute();
|
| 488 |
+
});
|
| 489 |
+
|
| 490 |
+
/* ── browse: genres & moods ─────────────────────────────────────────────── */
|
| 491 |
+
const GENRE_COLORS = [['#8b5cf6', '#22d3ee'], ['#f472b6', '#8b5cf6'], ['#22d3ee', '#4ade80'], ['#fbbf24', '#f472b6'], ['#60a5fa', '#a78bfa'], ['#4ade80', '#22d3ee']];
|
| 492 |
+
async function renderBrowse() {
|
| 493 |
+
const token = ++state.token;
|
| 494 |
+
main.innerHTML = `<div class="view-head"><div><h1>Genres & moods</h1><div class="sub">Browse YouTube Music moods, moments and genres</div></div></div>${skeletons(6)}`;
|
| 495 |
+
try {
|
| 496 |
+
const data = await api('/api/genres');
|
| 497 |
+
if (token !== state.token) return;
|
| 498 |
+
let ci = 0;
|
| 499 |
+
main.innerHTML = `<div class="view-head"><div><h1>Genres & moods</h1><div class="sub">Pick a vibe — every tile leads to ready-made playlists</div></div></div>` +
|
| 500 |
+
data.categories.map(cat => `
|
| 501 |
+
<section class="panel" style="margin-bottom:16px">
|
| 502 |
+
<div class="panel-head"><h2>${icon('queue')}${esc(cat.title)}</h2><span style="font-size:11.5px;color:var(--dim)">${cat.items.length} tiles</span></div>
|
| 503 |
+
<div class="panel-body">
|
| 504 |
+
<div class="tiles">${cat.items.map(it => {
|
| 505 |
+
const [a, b] = GENRE_COLORS[ci++ % GENRE_COLORS.length];
|
| 506 |
+
return `<button class="tile-btn" data-genre="${esc(it.params)}" data-title="${esc(it.title)}">
|
| 507 |
+
<span class="tb-glow" style="background:radial-gradient(circle,${a},${b})"></span>
|
| 508 |
+
<span class="tb-title">${esc(it.title)}</span>
|
| 509 |
+
<span class="tb-sub">Open playlists →</span></button>`;
|
| 510 |
+
}).join('')}</div>
|
| 511 |
+
</div>
|
| 512 |
+
</section>`).join('');
|
| 513 |
+
} catch (e) {
|
| 514 |
+
if (token !== state.token) return;
|
| 515 |
+
main.innerHTML = `<div class="empty-panel">Couldn't load genres — ${esc(e.message)}</div>`;
|
| 516 |
+
}
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
async function renderGenre(params, title) {
|
| 520 |
+
const token = ++state.token;
|
| 521 |
+
main.innerHTML = `<div class="crumbs"><button data-nav-to="browse">Genres & moods</button>› <span>${esc(title)}</span></div>${skeletons(6)}`;
|
| 522 |
+
try {
|
| 523 |
+
const data = await api(`/api/genre?params=${encodeURIComponent(params)}`);
|
| 524 |
+
if (token !== state.token) return;
|
| 525 |
+
main.innerHTML = `
|
| 526 |
+
<div class="crumbs"><button data-nav-to="browse">Genres & moods</button>› <span>${esc(title)}</span></div>
|
| 527 |
+
<div class="view-head"><div><h1>${esc(title)}</h1><div class="sub">${data.playlists.length} playlists curated by YouTube Music</div></div></div>
|
| 528 |
+
<div class="tiles">${data.playlists.map(p => `
|
| 529 |
+
<button class="tile-btn" data-playlist="${esc(p.id)}" data-title="${esc(p.title)}" style="min-height:168px;padding:0;display:flex;flex-direction:column">
|
| 530 |
+
<span style="display:block;width:100%;aspect-ratio:16/10;background:linear-gradient(135deg,#1e1e2f,#12121c);overflow:hidden">
|
| 531 |
+
${p.thumbnail ? `<img src="${imgp(p.thumbnail)}" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover">` : ''}
|
| 532 |
+
</span>
|
| 533 |
+
<span style="padding:11px 12px;display:block">
|
| 534 |
+
<span class="tb-title" style="font-size:13.5px">${esc(p.title)}</span>
|
| 535 |
+
<span class="tb-sub">${esc(p.count || '')} ${p.count ? 'songs' : 'playlist'}</span>
|
| 536 |
+
</span>
|
| 537 |
+
</button>`).join('')}</div>`;
|
| 538 |
+
} catch (e) {
|
| 539 |
+
if (token !== state.token) return;
|
| 540 |
+
main.innerHTML = `<div class="empty-panel">Couldn't load playlists — ${esc(e.message)}</div>`;
|
| 541 |
+
}
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
async function renderPlaylist(id, title) {
|
| 545 |
+
const token = ++state.token;
|
| 546 |
+
main.innerHTML = `<div class="crumbs"><button data-nav-to="browse">Genres & moods</button>› <span>${esc(title || 'Playlist')}</span></div>${skeletons(8)}`;
|
| 547 |
+
try {
|
| 548 |
+
const data = await api(`/api/playlist?id=${encodeURIComponent(id)}&limit=80`);
|
| 549 |
+
if (token !== state.token) return;
|
| 550 |
+
state.viewLists.playlist = data.tracks;
|
| 551 |
+
main.innerHTML = `
|
| 552 |
+
<div class="crumbs"><button data-nav-to="browse">Genres & moods</button>› <span>${esc(data.title || title)}</span></div>
|
| 553 |
+
<div class="view-head">
|
| 554 |
+
<div><h1>${esc(data.title || title)}</h1><div class="sub">${data.tracks.length} tracks${data.author ? ' · ' + esc(data.author) : ''}</div></div>
|
| 555 |
+
<button class="link-btn" data-action="play-list" data-list="playlist">${icon('play')}Play playlist</button>
|
| 556 |
+
</div>
|
| 557 |
+
<section class="panel"><div class="panel-body tight">${trackRows(data.tracks, 'playlist')}</div></section>`;
|
| 558 |
+
markNowPlaying();
|
| 559 |
+
} catch (e) {
|
| 560 |
+
if (token !== state.token) return;
|
| 561 |
+
main.innerHTML = `<div class="empty-panel">Couldn't load playlist — ${esc(e.message)}</div>`;
|
| 562 |
+
}
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
/* ── artist / album ─────────────────────────────────────────────────────── */
|
| 566 |
+
async function renderArtistView(id, name) {
|
| 567 |
+
const token = ++state.token;
|
| 568 |
+
main.innerHTML = `<div class="view-head"><div><h1>${esc(name || '…')}</h1><div class="sub">Loading artist…</div></div></div>${skeletons(8)}`;
|
| 569 |
+
try {
|
| 570 |
+
const qs = (id && id !== 'undefined') ? `id=${encodeURIComponent(id)}` : `name=${encodeURIComponent(name || '')}`;
|
| 571 |
+
const a = await api(`/api/artist?${qs}`);
|
| 572 |
+
if (token !== state.token) return;
|
| 573 |
+
state.viewLists.top = a.top_tracks;
|
| 574 |
+
const listeners = a.monthly_listeners ? `${Number(a.monthly_listeners).toLocaleString()} monthly listeners` : '';
|
| 575 |
+
main.innerHTML = `
|
| 576 |
+
<div class="artist-head">
|
| 577 |
+
<img src="${a.thumbnail ? imgp(a.thumbnail) : ''}" alt="" onerror="this.style.visibility='hidden'">
|
| 578 |
+
<div class="ah-text">
|
| 579 |
+
<div class="eyebrow">Artist</div>
|
| 580 |
+
<h1>${esc(a.name)}</h1>
|
| 581 |
+
<div class="stats">${[a.subscribers ? `${a.subscribers} subscribers` : '', listeners].filter(Boolean).join(' · ')}</div>
|
| 582 |
+
${a.description ? `<p class="desc">${esc(a.description)}</p>` : ''}
|
| 583 |
+
<div class="chips" style="margin-top:14px">
|
| 584 |
+
<button class="chip strong" data-action="artist-radio" data-artist-id="${esc(a.id)}" data-artist-name="${esc(a.name)}">${icon('spark')} Play artist radio</button>
|
| 585 |
+
${a.top_tracks.length ? `<button class="chip" data-action="play-list" data-list="top">${icon('play')} Play top songs</button>` : ''}
|
| 586 |
+
</div>
|
| 587 |
+
</div>
|
| 588 |
+
</div>
|
| 589 |
+
<div class="grid2">
|
| 590 |
+
<section class="panel">
|
| 591 |
+
<div class="panel-head"><h2>${icon('play')}Top songs</h2></div>
|
| 592 |
+
<div class="panel-body tight">${trackRows(a.top_tracks, 'top')}</div>
|
| 593 |
+
</section>
|
| 594 |
+
<div style="display:flex;flex-direction:column;gap:16px">
|
| 595 |
+
${a.similar.length ? `<section class="panel">
|
| 596 |
+
<div class="panel-head"><h2>${icon('artist')}Fans also like</h2></div>
|
| 597 |
+
<div class="artist-strip">${a.similar.map(s => artistCard({ id: s.id, name: s.name, thumbnail: s.thumbnail })).join('')}</div></section>` : ''}
|
| 598 |
+
${a.albums.length ? `<section class="panel">
|
| 599 |
+
<div class="panel-head"><h2>${icon('queue')}Albums & singles</h2></div>
|
| 600 |
+
<div class="panel-body tight">${a.albums.map(al => `
|
| 601 |
+
<div class="feed-row" data-album="${esc(al.id)}" style="grid-template-columns:minmax(0,1fr) auto">
|
| 602 |
+
<div style="min-width:0"><div class="ft">${esc(al.title)}</div><div class="fa">${esc(al.type)}${al.year ? ' · ' + al.year : ''}</div></div>
|
| 603 |
+
<span style="color:var(--dim);font-size:12px">open →</span>
|
| 604 |
+
</div>`).join('')}</div></section>` : ''}
|
| 605 |
+
</div>
|
| 606 |
+
</div>`;
|
| 607 |
+
markNowPlaying();
|
| 608 |
+
} catch (e) {
|
| 609 |
+
if (token !== state.token) return;
|
| 610 |
+
main.innerHTML = `<div class="empty-panel">Couldn't load artist — ${esc(e.message)}</div>`;
|
| 611 |
+
}
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
async function renderAlbum(id) {
|
| 615 |
+
const token = ++state.token;
|
| 616 |
+
main.innerHTML = `<div class="crumbs"><button data-nav-to="overview">Dashboard</button>› <span>Album</span></div>${skeletons(8)}`;
|
| 617 |
+
try {
|
| 618 |
+
const al = await api(`/api/album?id=${encodeURIComponent(id)}`);
|
| 619 |
+
if (token !== state.token) return;
|
| 620 |
+
state.viewLists.album = al.tracks;
|
| 621 |
+
main.innerHTML = `
|
| 622 |
+
<div class="crumbs"><button data-nav-to="overview">Dashboard</button>› <span>Album</span></div>
|
| 623 |
+
<div class="view-head">
|
| 624 |
+
<div><h1>${esc(al.title)}</h1><div class="sub">${[al.year, `${al.tracks.length} tracks`].filter(Boolean).join(' · ')}</div></div>
|
| 625 |
+
<button class="link-btn" data-action="play-list" data-list="album">${icon('play')}Play album</button>
|
| 626 |
+
</div>
|
| 627 |
+
<section class="panel"><div class="panel-body tight">${trackRows(al.tracks, 'album')}</div></section>`;
|
| 628 |
+
markNowPlaying();
|
| 629 |
+
} catch (e) {
|
| 630 |
+
if (token !== state.token) return;
|
| 631 |
+
main.innerHTML = `<div class="empty-panel">Couldn't load album — ${esc(e.message)}</div>`;
|
| 632 |
+
}
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
/* ── recently played ────────────────────────────────────────────────────── */
|
| 636 |
+
function renderRecentView() {
|
| 637 |
+
state.token++;
|
| 638 |
+
const recent = LS.get('recent', []);
|
| 639 |
+
state.viewLists.recent = recent;
|
| 640 |
+
main.innerHTML = `
|
| 641 |
+
<div class="view-head">
|
| 642 |
+
<div><h1>Recently played</h1><div class="sub">${recent.length} tracks from this browser</div></div>
|
| 643 |
+
${recent.length ? `<button class="link-btn" data-action="play-list" data-list="recent">${icon('play')}Play all</button>` : ''}
|
| 644 |
+
</div>
|
| 645 |
+
<section class="panel"><div class="panel-body tight">${recent.length ? trackRows(recent, 'recent') : '<div class="empty-panel">Nothing played yet.</div>'}</div></section>`;
|
| 646 |
+
markNowPlaying();
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
/* ── playback ───────────────────────────────────────────────────────────── */
|
| 650 |
+
function setQueue(list, startIndex, label, station = null) {
|
| 651 |
+
state.queue = list.slice();
|
| 652 |
+
state.queueSource = list;
|
| 653 |
+
state.index = startIndex;
|
| 654 |
+
state.queueLabel = label || null;
|
| 655 |
+
state.station = station || null;
|
| 656 |
+
refreshQueue();
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
function playTrackAt(i, { addToHistory = true } = {}) {
|
| 660 |
+
if (i < 0 || i >= state.queue.length) return;
|
| 661 |
+
const t = state.queue[i];
|
| 662 |
+
if (addToHistory && state.current) state.history = state.history || [];
|
| 663 |
+
state.index = i;
|
| 664 |
+
state.current = t;
|
| 665 |
+
|
| 666 |
+
$('#npTitle').textContent = t.title;
|
| 667 |
+
$('#npArtist').innerHTML = (t.artists || []).map(artistLink).join(', ') || esc(artistName(t));
|
| 668 |
+
$('#npArt').innerHTML = `<img src="${thumb(t.video_id)}" alt="">`;
|
| 669 |
+
$('#radioBadge').hidden = !state.station;
|
| 670 |
+
document.title = `${t.title} · ${artistName(t)} — Cadence`;
|
| 671 |
+
updateMediaSession(t);
|
| 672 |
+
|
| 673 |
+
setLoading(true);
|
| 674 |
+
audio.src = `/api/stream/${t.video_id}`;
|
| 675 |
+
audio.play().catch(err => {
|
| 676 |
+
setLoading(false);
|
| 677 |
+
if (!err || err.name === 'AbortError') return;
|
| 678 |
+
// give the stream diagnosis a moment to speak first — it knows the real reason
|
| 679 |
+
setTimeout(() => { if (!blockingReported) toast('Playback blocked — press play to start', true); }, 1500);
|
| 680 |
+
});
|
| 681 |
+
|
| 682 |
+
remember(t);
|
| 683 |
+
bufferAhead();
|
| 684 |
+
markNowPlaying();
|
| 685 |
+
refreshQueue();
|
| 686 |
+
paintNowPlaying();
|
| 687 |
+
paintFeed();
|
| 688 |
+
maybeGrow();
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
function remember(t) {
|
| 692 |
+
const recent = LS.get('recent', []).filter(x => x.video_id !== t.video_id);
|
| 693 |
+
recent.unshift({ video_id: t.video_id, title: t.title, artists: t.artists, artist_text: t.artist_text, album: t.album, duration: t.duration });
|
| 694 |
+
LS.set('recent', recent.slice(0, 20));
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
function togglePlay() {
|
| 698 |
+
if (!state.current) { go('/search'); toast('Pick a song first 🙂'); return; }
|
| 699 |
+
if (audio.paused) audio.play().catch(() => {}); else audio.pause();
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
function next(auto = false) {
|
| 703 |
+
if (!state.queue.length) return;
|
| 704 |
+
if (state.repeat === 2 && auto) { audio.currentTime = 0; audio.play(); return; }
|
| 705 |
+
let i;
|
| 706 |
+
if (state.shuffle) { do { i = Math.floor(Math.random() * state.queue.length); } while (state.queue.length > 1 && i === state.index); }
|
| 707 |
+
else i = state.index + 1;
|
| 708 |
+
if (i >= state.queue.length) {
|
| 709 |
+
if (state.repeat === 1 || auto) {
|
| 710 |
+
growQueue().then(grew => {
|
| 711 |
+
if (grew) playTrackAt(state.index + 1);
|
| 712 |
+
else if (state.repeat === 1) playTrackAt(0);
|
| 713 |
+
else { toast('End of queue'); audio.pause(); }
|
| 714 |
+
});
|
| 715 |
+
return;
|
| 716 |
+
}
|
| 717 |
+
playTrackAt(0); return;
|
| 718 |
+
}
|
| 719 |
+
playTrackAt(i);
|
| 720 |
+
}
|
| 721 |
+
function prev() {
|
| 722 |
+
if (audio.currentTime > 4 || !state.queue.length) { audio.currentTime = 0; return; }
|
| 723 |
+
playTrackAt(Math.max(0, state.index - 1));
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
async function growQueue(force = false) {
|
| 727 |
+
if (state.growing) return false;
|
| 728 |
+
const seed = (state.current && state.current.video_id) || null;
|
| 729 |
+
if (!seed) return false;
|
| 730 |
+
state.growing = true;
|
| 731 |
+
try {
|
| 732 |
+
const data = await api(`/api/radio?videoId=${seed}&limit=25`);
|
| 733 |
+
const have = new Set(state.queue.map(t => t.video_id));
|
| 734 |
+
const fresh = (data.tracks || []).filter(t => !have.has(t.video_id));
|
| 735 |
+
if (!fresh.length) return false;
|
| 736 |
+
state.queue.push(...fresh);
|
| 737 |
+
state.suggestionsAdded += fresh.length;
|
| 738 |
+
state.station = 'Radio · ' + (state.current ? artistName(state.current) : 'station');
|
| 739 |
+
refreshQueue(); paintFeed(); paintStats();
|
| 740 |
+
$('#radioBadge').hidden = false;
|
| 741 |
+
if (force) toast(`+${fresh.length} suggestions queued`);
|
| 742 |
+
return true;
|
| 743 |
+
} catch (e) {
|
| 744 |
+
toast('Couldn\'t load suggestions: ' + e.message, true);
|
| 745 |
+
return false;
|
| 746 |
+
} finally { state.growing = false; }
|
| 747 |
+
}
|
| 748 |
+
function maybeGrow() { if (state.shuffle || state.repeat === 2) return; if (state.queue.length - state.index <= 4) growQueue(); }
|
| 749 |
+
|
| 750 |
+
/* buffer-ahead telemetry */
|
| 751 |
+
async function bufferAhead() {
|
| 752 |
+
const nxt = state.queue[state.index + 1];
|
| 753 |
+
state.buffered = { id: nxt ? nxt.video_id : null, ready: false, bytes: 0 };
|
| 754 |
+
paintStats();
|
| 755 |
+
if (!nxt) return;
|
| 756 |
+
try { await fetch(`/api/prefetch/${nxt.video_id}`, { method: 'POST' }); } catch {}
|
| 757 |
+
for (let attempt = 0; attempt < 24; attempt++) {
|
| 758 |
+
await new Promise(r => setTimeout(r, attempt < 6 ? 250 : 800));
|
| 759 |
+
if (state.buffered.id !== nxt.video_id) return; // track changed meanwhile
|
| 760 |
+
try {
|
| 761 |
+
const d = await api(`/api/ready/${nxt.video_id}`);
|
| 762 |
+
if (d.cached) { state.buffered.ready = true; state.buffered.bytes = d.bytes; paintStats(); return; }
|
| 763 |
+
} catch {}
|
| 764 |
+
}
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
async function playSimilar(t) {
|
| 768 |
+
toast(`Finding songs similar to "${t.title}"…`);
|
| 769 |
+
try {
|
| 770 |
+
const data = await api(`/api/radio?videoId=${t.video_id}&limit=25`);
|
| 771 |
+
const rest = (data.tracks || []).filter(x => x.video_id !== t.video_id);
|
| 772 |
+
if (!rest.length) throw new Error('no suggestions came back');
|
| 773 |
+
setQueue([t, ...rest], 0, `Similar to ${t.title}`, `Radio · ${artistName(t)}`);
|
| 774 |
+
state.suggestionsAdded += rest.length;
|
| 775 |
+
playTrackAt(0);
|
| 776 |
+
toast(`▶ ${rest.length} songs like ${artistName(t)} queued`);
|
| 777 |
+
} catch (e) { toast("Couldn't get similar songs: " + e.message, true); }
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
async function playArtistRadio({ id, name }) {
|
| 781 |
+
toast(`Tuning ${name} radio…`);
|
| 782 |
+
try {
|
| 783 |
+
const data = await api(`/api/artist/radio?id=${encodeURIComponent(id)}&limit=30`);
|
| 784 |
+
const tracks = data.tracks || [];
|
| 785 |
+
if (!tracks.length) throw new Error('station came back empty');
|
| 786 |
+
setQueue(tracks, 0, `Artist radio · ${data.station_name || name}`, `Artist radio · ${data.station_name || name}`);
|
| 787 |
+
state.suggestionsAdded += tracks.length;
|
| 788 |
+
playTrackAt(0);
|
| 789 |
+
toast(`▶ ${data.station_name || name} radio — ${tracks.length} tracks`);
|
| 790 |
+
} catch (e) { toast('Radio failed: ' + e.message, true); }
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
/* ── queue rail ─────────────────────────────────────────────────────────── */
|
| 794 |
+
function refreshQueue(label) {
|
| 795 |
+
const list = $('#queueList');
|
| 796 |
+
if (!list) return;
|
| 797 |
+
if (!state.queue.length) {
|
| 798 |
+
list.innerHTML = `<div class="queue-empty">Nothing queued yet.<br>Play a song and Cadence will suggest what's next.</div>`;
|
| 799 |
+
$('#queueSub').textContent = 'Suggestions appear as you listen';
|
| 800 |
+
return;
|
| 801 |
+
}
|
| 802 |
+
$('#queueTitle').textContent = label || state.queueLabel || 'Up next';
|
| 803 |
+
$('#queueSub').textContent = `Track ${state.index + 1} of ${state.queue.length}`;
|
| 804 |
+
list.innerHTML = state.queue.map((t, i) => `
|
| 805 |
+
<li class="qrow ${state.current && t.video_id === state.current.video_id ? 'now' : ''}" data-vid="${esc(t.video_id)}" data-qi="${i}">
|
| 806 |
+
<img src="${thumb(t.video_id)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">
|
| 807 |
+
<div><div class="qt">${esc(t.title)}</div><div class="qa">${esc(artistName(t))}</div></div>
|
| 808 |
+
<span class="qd">${esc(fmtTime(t.duration))}</span>
|
| 809 |
+
</li>`).join('');
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
/* ── audio plumbing ─────────────────────────────────────────────────────── */
|
| 813 |
+
function setLoading(on) { $('#spinner').hidden = !on; }
|
| 814 |
+
let errStreak = 0;
|
| 815 |
+
audio.addEventListener('playing', () => { setLoading(false); setPlayIcon(true); markNowPlaying(); updateHeroState(); errStreak = 0; blockingReported = false; if (state.queue.length - state.index <= 4) growQueue(); });
|
| 816 |
+
audio.addEventListener('pause', () => { setPlayIcon(false); updateHeroState(); });
|
| 817 |
+
audio.addEventListener('waiting', () => setLoading(true));
|
| 818 |
+
audio.addEventListener('canplay', () => setLoading(false));
|
| 819 |
+
audio.addEventListener('timeupdate', () => {
|
| 820 |
+
const d = audio.duration || (state.current && state.current.duration) || 0;
|
| 821 |
+
const p = d ? (audio.currentTime / d) : 0;
|
| 822 |
+
$('#tCur').textContent = fmtTime(audio.currentTime);
|
| 823 |
+
$('#tDur').textContent = fmtTime(d);
|
| 824 |
+
if (!seeking) { $('#seek').value = Math.round(p * 1000); $('#seekFill').style.width = (p * 100) + '%'; }
|
| 825 |
+
if ($('#heroFacts')) paintStats();
|
| 826 |
+
});
|
| 827 |
+
audio.addEventListener('ended', () => next(true));
|
| 828 |
+
audio.addEventListener('error', async () => {
|
| 829 |
+
setLoading(false);
|
| 830 |
+
if (!state.current) return;
|
| 831 |
+
const id = state.current.video_id;
|
| 832 |
+
const info = await diagnoseStream(id);
|
| 833 |
+
|
| 834 |
+
// YouTube refusing this server's IP won't be fixed by skipping tracks
|
| 835 |
+
if (info.kind === 'youtube_blocked') { reportBlocked(info); audio.pause(); return; }
|
| 836 |
+
if (info.kind === 'offline') {
|
| 837 |
+
toast('Backend unreachable — is the Flask app still running?', true, 9000);
|
| 838 |
+
audio.pause(); return;
|
| 839 |
+
}
|
| 840 |
+
|
| 841 |
+
if (++errStreak > 3) { toast('Several tracks failed to stream — pausing', true); audio.pause(); return; }
|
| 842 |
+
toast(`Couldn't stream "${state.current.title}" — skipping`, true);
|
| 843 |
+
setTimeout(() => next(true), 700);
|
| 844 |
+
});
|
| 845 |
+
audio.volume = LS.get('volume', 0.9);
|
| 846 |
+
$('#vol').value = audio.volume;
|
| 847 |
+
|
| 848 |
+
function setPlayIcon(playing) {
|
| 849 |
+
$('#playBtn').innerHTML = icon(playing ? 'pause' : 'play');
|
| 850 |
+
$('#playBtn').title = playing ? 'Pause (Space)' : 'Play (Space)';
|
| 851 |
+
}
|
| 852 |
+
setPlayIcon(false);
|
| 853 |
+
|
| 854 |
+
let seeking = false;
|
| 855 |
+
const seek = $('#seek');
|
| 856 |
+
['input', 'change'].forEach(ev => seek.addEventListener(ev, () => {
|
| 857 |
+
const d = audio.duration || 0;
|
| 858 |
+
if (d) {
|
| 859 |
+
const t = (seek.value / 1000) * d;
|
| 860 |
+
$('#tCur').textContent = fmtTime(t);
|
| 861 |
+
$('#seekFill').style.width = (seek.value / 10) + '%';
|
| 862 |
+
$('#tDur').textContent = fmtTime(d);
|
| 863 |
+
}
|
| 864 |
+
if (ev === 'input') seeking = true;
|
| 865 |
+
if (ev === 'change') { if (d) audio.currentTime = (seek.value / 1000) * d; seeking = false; }
|
| 866 |
+
}));
|
| 867 |
+
seek.addEventListener('pointerup', () => { seeking = false; });
|
| 868 |
+
|
| 869 |
+
$('#vol').addEventListener('input', e => {
|
| 870 |
+
audio.volume = Number(e.target.value);
|
| 871 |
+
if (audio.volume > 0) audio.muted = false;
|
| 872 |
+
LS.set('volume', audio.volume); syncMute();
|
| 873 |
+
});
|
| 874 |
+
function syncMute() { $('#muteBtn').innerHTML = icon(audio.muted || audio.volume === 0 ? 'mute' : 'vol'); }
|
| 875 |
+
$('#muteBtn').addEventListener('click', () => { audio.muted = !audio.muted; syncMute(); });
|
| 876 |
+
syncMute();
|
| 877 |
+
|
| 878 |
+
$('#playBtn').addEventListener('click', togglePlay);
|
| 879 |
+
$('#nextBtn').addEventListener('click', () => next(false));
|
| 880 |
+
$('#prevBtn').addEventListener('click', prev);
|
| 881 |
+
$('#shuffleBtn').addEventListener('click', e => { state.shuffle = !state.shuffle; e.currentTarget.classList.toggle('on', state.shuffle); toast(state.shuffle ? 'Shuffle on' : 'Shuffle off'); });
|
| 882 |
+
$('#repeatBtn').addEventListener('click', e => {
|
| 883 |
+
state.repeat = (state.repeat + 1) % 3;
|
| 884 |
+
e.currentTarget.classList.toggle('on', state.repeat > 0);
|
| 885 |
+
e.currentTarget.innerHTML = icon(state.repeat === 2 ? 'repeat1' : 'repeat');
|
| 886 |
+
toast(['Repeat off', 'Repeat queue', 'Repeat track'][state.repeat]);
|
| 887 |
+
});
|
| 888 |
+
$('#queueList').addEventListener('click', e => { const row = e.target.closest('.qrow'); if (row) playTrackAt(Number(row.dataset.qi)); });
|
| 889 |
+
$('#clearQueue').addEventListener('click', () => {
|
| 890 |
+
const keep = state.current;
|
| 891 |
+
state.queue = keep ? [keep] : []; state.index = keep ? 0 : -1; state.station = null;
|
| 892 |
+
$('#radioBadge').hidden = true; refreshQueue(); paintFeed(); paintStats(); toast('Queue cleared');
|
| 893 |
+
});
|
| 894 |
+
$('#queueToggle').addEventListener('click', e => {
|
| 895 |
+
const narrow = window.matchMedia('(max-width:900px)').matches;
|
| 896 |
+
if (narrow) $('#rail').classList.toggle('open');
|
| 897 |
+
else $('.body').classList.toggle('rail-hidden');
|
| 898 |
+
e.currentTarget.classList.toggle('active');
|
| 899 |
+
});
|
| 900 |
+
|
| 901 |
+
/* ── click delegation in the main area ──────────────────────────────────── */
|
| 902 |
+
main.addEventListener('click', e => {
|
| 903 |
+
const navTo = e.target.closest('[data-nav-to]');
|
| 904 |
+
if (navTo) { go('/' + navTo.dataset.navTo); return; }
|
| 905 |
+
|
| 906 |
+
const artistEl = e.target.closest('[data-artist]');
|
| 907 |
+
if (artistEl) {
|
| 908 |
+
e.preventDefault(); // hash href keeps working when this is a synthetic click too
|
| 909 |
+
const id = artistEl.dataset.artistId || '';
|
| 910 |
+
const nm = artistEl.dataset.artistName || '';
|
| 911 |
+
if (id || nm) go(`/artist/${id ? encodeURIComponent(id) : ''}?name=${encodeURIComponent(nm)}`);
|
| 912 |
+
return;
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
const albumEl = e.target.closest('[data-album]');
|
| 916 |
+
if (albumEl) { e.preventDefault(); go(`/album/${encodeURIComponent(albumEl.dataset.album)}`); return; }
|
| 917 |
+
|
| 918 |
+
const genreEl = e.target.closest('[data-genre]');
|
| 919 |
+
if (genreEl) { go(`/genre/${encodeURIComponent(genreEl.dataset.genre)}?title=${encodeURIComponent(genreEl.dataset.title)}`); return; }
|
| 920 |
+
|
| 921 |
+
const plEl = e.target.closest('[data-playlist]');
|
| 922 |
+
if (plEl) { go(`/playlist/${encodeURIComponent(plEl.dataset.playlist)}?title=${encodeURIComponent(plEl.dataset.title)}`); return; }
|
| 923 |
+
|
| 924 |
+
const chip = e.target.closest('[data-search]');
|
| 925 |
+
if (chip) { $('#q').value = chip.dataset.search; runSearch(chip.dataset.search); return; }
|
| 926 |
+
|
| 927 |
+
const act = e.target.closest('[data-action]');
|
| 928 |
+
if (act) {
|
| 929 |
+
const a = act.dataset.action;
|
| 930 |
+
if (a === 'queue') {
|
| 931 |
+
e.stopPropagation();
|
| 932 |
+
const row = e.target.closest('.trow');
|
| 933 |
+
const list = state.viewLists[row.dataset.list] || [];
|
| 934 |
+
const t = list[Number(row.dataset.i)];
|
| 935 |
+
if (t) { state.queue.push(t); refreshQueue(); paintFeed(); paintStats(); toast(`Added "${t.title}" to queue`); }
|
| 936 |
+
return;
|
| 937 |
+
}
|
| 938 |
+
if (a === 'similar') {
|
| 939 |
+
e.stopPropagation();
|
| 940 |
+
const row = e.target.closest('.trow');
|
| 941 |
+
const list = state.viewLists[row.dataset.list] || [];
|
| 942 |
+
const t = list[Number(row.dataset.i)];
|
| 943 |
+
if (t) playSimilar(t);
|
| 944 |
+
return;
|
| 945 |
+
}
|
| 946 |
+
if (a === 'similar-now' && state.current) { playSimilar(state.current); return; }
|
| 947 |
+
if (a === 'open-settings') {
|
| 948 |
+
if (window.__SRC__ && window.__SRC__.openSettings) window.__SRC__.openSettings();
|
| 949 |
+
else toast('Settings are only available in the standalone file', true);
|
| 950 |
+
return;
|
| 951 |
+
}
|
| 952 |
+
if (a === 'playpause') { togglePlay(); return; }
|
| 953 |
+
if (a === 'play-list') {
|
| 954 |
+
const list = state.viewLists[act.dataset.list] || [];
|
| 955 |
+
if (list.length) { setQueue(list, 0, LABELS[act.dataset.list] || 'Playing list'); playTrackAt(0); }
|
| 956 |
+
return;
|
| 957 |
+
}
|
| 958 |
+
if (a === 'artist-radio') { playArtistRadio({ id: act.dataset.artistId, name: act.dataset.artistName }); return; }
|
| 959 |
+
if (a === 'surprise') {
|
| 960 |
+
const picks = ['Lo-fi hip hop', 'Daft Punk', 'Fleetwood Mac', 'Kendrick Lamar', 'Tame Impala', 'Nina Simone', 'Radiohead', 'Khruangbin'];
|
| 961 |
+
const pick = picks[Math.floor(Math.random() * picks.length)];
|
| 962 |
+
$('#q').value = pick; runSearch(pick);
|
| 963 |
+
return;
|
| 964 |
+
}
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
const row = e.target.closest('.trow');
|
| 968 |
+
if (row) {
|
| 969 |
+
const list = state.viewLists[row.dataset.list] || [];
|
| 970 |
+
const i = Number(row.dataset.i);
|
| 971 |
+
if (list.length) {
|
| 972 |
+
if (state.queueSource !== list) setQueue(list, i, LABELS[row.dataset.list] || 'Playing list');
|
| 973 |
+
playTrackAt(i);
|
| 974 |
+
}
|
| 975 |
+
}
|
| 976 |
+
});
|
| 977 |
+
|
| 978 |
+
/* suggestion feed rows live outside `main` re-renders when re-painted: delegate on document */
|
| 979 |
+
document.addEventListener('click', e => {
|
| 980 |
+
const row = e.target.closest('.feed-row[data-qi]');
|
| 981 |
+
if (row) playTrackAt(Number(row.dataset.qi));
|
| 982 |
+
});
|
| 983 |
+
|
| 984 |
+
/* nav + hash routing */
|
| 985 |
+
document.querySelectorAll('.nav-item').forEach(b => b.addEventListener('click', () => go('/' + b.dataset.nav)));
|
| 986 |
+
window.addEventListener('hashchange', renderRoute);
|
| 987 |
+
|
| 988 |
+
/* ── search box ─────────────────────────────────────────────────────────── */
|
| 989 |
+
let suggestTimer = null;
|
| 990 |
+
const qInput = $('#q');
|
| 991 |
+
function runSearch(q) { $('#q').value = q; hideSuggest(); go(`/search/${encodeURIComponent(q)}?q=${encodeURIComponent(q)}`); if (currentRoute() === `/search/${encodeURIComponent(q)}?q=${encodeURIComponent(q)}`) renderRoute(); }
|
| 992 |
+
qInput.addEventListener('input', () => {
|
| 993 |
+
const v = qInput.value.trim();
|
| 994 |
+
clearTimeout(suggestTimer);
|
| 995 |
+
if (v.length < 2) { hideSuggest(); return; }
|
| 996 |
+
suggestTimer = setTimeout(fetchSuggestions, 160);
|
| 997 |
+
});
|
| 998 |
+
qInput.addEventListener('keydown', e => {
|
| 999 |
+
const box = $('#suggest');
|
| 1000 |
+
const open = !box.hidden && state.suggestItems && state.suggestItems.length;
|
| 1001 |
+
if (e.key === 'ArrowDown' && open) { e.preventDefault(); state.suggestActive = Math.min((state.suggestActive ?? -1) + 1, state.suggestItems.length - 1); paintSuggest(); }
|
| 1002 |
+
else if (e.key === 'ArrowUp' && open) { e.preventDefault(); state.suggestActive = Math.max((state.suggestActive ?? -1) - 1, -1); paintSuggest(); }
|
| 1003 |
+
else if (e.key === 'Enter') {
|
| 1004 |
+
const v = (open && (state.suggestActive ?? -1) >= 0) ? state.suggestItems[state.suggestActive] : qInput.value;
|
| 1005 |
+
if (v && v.trim()) runSearch(v.trim());
|
| 1006 |
+
} else if (e.key === 'Escape') hideSuggest();
|
| 1007 |
+
});
|
| 1008 |
+
async function fetchSuggestions() {
|
| 1009 |
+
const v = qInput.value.trim();
|
| 1010 |
+
if (v.length < 2) return;
|
| 1011 |
+
try {
|
| 1012 |
+
const data = await api(`/api/suggest?q=${encodeURIComponent(v)}`);
|
| 1013 |
+
if (qInput.value.trim() !== v) return;
|
| 1014 |
+
state.suggestItems = data.suggestions || [];
|
| 1015 |
+
state.suggestActive = -1;
|
| 1016 |
+
paintSuggest();
|
| 1017 |
+
} catch {}
|
| 1018 |
+
}
|
| 1019 |
+
function paintSuggest() {
|
| 1020 |
+
const box = $('#suggest');
|
| 1021 |
+
const items = state.suggestItems || [];
|
| 1022 |
+
if (!items.length) { hideSuggest(); return; }
|
| 1023 |
+
box.innerHTML = items.map((s, i) => `<div class="${i === state.suggestActive ? 'active' : ''}" data-s="${esc(s)}">${icon('search')}<span>${esc(s)}</span></div>`).join('');
|
| 1024 |
+
box.hidden = false;
|
| 1025 |
+
}
|
| 1026 |
+
function hideSuggest() { $('#suggest').hidden = true; state.suggestActive = -1; }
|
| 1027 |
+
$('#suggest').addEventListener('mousedown', e => { const el = e.target.closest('[data-s]'); if (el) { e.preventDefault(); runSearch(el.dataset.s); } });
|
| 1028 |
+
document.addEventListener('click', e => { if (!e.target.closest('.searchwrap')) hideSuggest(); });
|
| 1029 |
+
|
| 1030 |
+
/* ── keyboard + media session ───────────────────────────────────────────── */
|
| 1031 |
+
document.addEventListener('keydown', e => {
|
| 1032 |
+
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName);
|
| 1033 |
+
if (e.key === '/' && !typing) { e.preventDefault(); qInput.focus(); qInput.select(); return; }
|
| 1034 |
+
if (typing || e.metaKey || e.ctrlKey || e.altKey) return;
|
| 1035 |
+
switch (e.key.toLowerCase()) {
|
| 1036 |
+
case ' ': e.preventDefault(); togglePlay(); break;
|
| 1037 |
+
case 'n': next(false); break;
|
| 1038 |
+
case 'p': prev(); break;
|
| 1039 |
+
case 's': $('#shuffleBtn').click(); break;
|
| 1040 |
+
case 'r': $('#repeatBtn').click(); break;
|
| 1041 |
+
case 'm': $('#muteBtn').click(); break;
|
| 1042 |
+
case 'q': $('#queueToggle').click(); break;
|
| 1043 |
+
case 'arrowright': audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + 10); break;
|
| 1044 |
+
case 'arrowleft': audio.currentTime = Math.max(0, audio.currentTime - 10); break;
|
| 1045 |
+
}
|
| 1046 |
+
});
|
| 1047 |
+
if ('mediaSession' in navigator) {
|
| 1048 |
+
navigator.mediaSession.setActionHandler('play', togglePlay);
|
| 1049 |
+
navigator.mediaSession.setActionHandler('pause', togglePlay);
|
| 1050 |
+
navigator.mediaSession.setActionHandler('nexttrack', () => next(false));
|
| 1051 |
+
navigator.mediaSession.setActionHandler('previoustrack', prev);
|
| 1052 |
+
}
|
| 1053 |
+
function updateMediaSession(t) {
|
| 1054 |
+
if (!('mediaSession' in navigator) || !t) return;
|
| 1055 |
+
try {
|
| 1056 |
+
navigator.mediaSession.metadata = new MediaMetadata({
|
| 1057 |
+
title: t.title, artist: artistName(t), album: (t.album && t.album.name) || 'Cadence',
|
| 1058 |
+
artwork: [{ src: thumb(t.video_id), sizes: '320x180', type: 'image/jpeg' }],
|
| 1059 |
+
});
|
| 1060 |
+
} catch {}
|
| 1061 |
+
}
|
| 1062 |
+
|
| 1063 |
+
/* ── engine telemetry poll ──────────────────────────────────────────────── */
|
| 1064 |
+
let backendWarned = false;
|
| 1065 |
+
async function pollStats() {
|
| 1066 |
+
if (document.hidden) return;
|
| 1067 |
+
try {
|
| 1068 |
+
const s = await api('/api/stats');
|
| 1069 |
+
backendWarned = false;
|
| 1070 |
+
$('#enCache').textContent = fmtBytes(s.cached_bytes);
|
| 1071 |
+
$('#enTracks').textContent = s.cached_tracks;
|
| 1072 |
+
$('#enDownloads').textContent = s.downloads + (s.download_failures ? ` (${s.download_failures} err)` : '');
|
| 1073 |
+
$('#enAvg').textContent = s.avg_download_seconds ? `${s.avg_download_seconds}s` : '–';
|
| 1074 |
+
$('#enInflight').textContent = s.downloads_inflight;
|
| 1075 |
+
$('#enUptime').textContent = fmtDur(s.uptime_seconds);
|
| 1076 |
+
$('#enBar').style.width = Math.min(100, (s.cached_bytes / s.cache_limit_bytes) * 100).toFixed(1) + '%';
|
| 1077 |
+
} catch {
|
| 1078 |
+
if (!backendWarned) {
|
| 1079 |
+
backendWarned = true;
|
| 1080 |
+
toast('Backend not reachable — start it with ./run.sh (search, streaming and suggestions need it)', true, 9000);
|
| 1081 |
+
}
|
| 1082 |
+
}
|
| 1083 |
+
}
|
| 1084 |
+
pollStats();
|
| 1085 |
+
setInterval(pollStats, 5000);
|
| 1086 |
+
|
| 1087 |
+
/* ── boot ───────────────────────────────────────────────────────────────── */
|
| 1088 |
+
$('#brandLink').addEventListener('click', e => { e.preventDefault(); go('/overview'); });
|
| 1089 |
+
if (!location.hash) location.replace('#/overview');
|
| 1090 |
+
renderRoute();
|
| 1091 |
+
console.log('%c🎧 Cadence dashboard ready', 'color:#a78bfa;font-weight:bold');
|
| 1092 |
+
})();
|
static/pwa/icons/apple-touch-icon.png
ADDED
|
|
static/pwa/icons/favicon-64.png
ADDED
|
|
static/pwa/icons/icon-1024.png
ADDED
|
|
static/pwa/icons/icon-128.png
ADDED
|
|
static/pwa/icons/icon-180.png
ADDED
|
|
static/pwa/icons/icon-192.png
ADDED
|
|
static/pwa/icons/icon-512.png
ADDED
|
|
static/pwa/icons/icon-96.png
ADDED
|
|
static/pwa/icons/icon-maskable-512.png
ADDED
|
|
static/pwa/manifest.webmanifest
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Cadence \u2014 music dashboard",
|
| 3 |
+
"short_name": "Cadence",
|
| 4 |
+
"description": "Search YouTube Music, build radios from any song, and keep the queue growing. Dashboard with live playback telemetry.",
|
| 5 |
+
"start_url": "/",
|
| 6 |
+
"scope": "/",
|
| 7 |
+
"display": "standalone",
|
| 8 |
+
"display_override": [
|
| 9 |
+
"standalone",
|
| 10 |
+
"fullscreen",
|
| 11 |
+
"minimal-ui"
|
| 12 |
+
],
|
| 13 |
+
"orientation": "portrait-primary",
|
| 14 |
+
"background_color": "#08080d",
|
| 15 |
+
"theme_color": "#08080d",
|
| 16 |
+
"categories": [
|
| 17 |
+
"music",
|
| 18 |
+
"entertainment"
|
| 19 |
+
],
|
| 20 |
+
"lang": "en",
|
| 21 |
+
"dir": "ltr",
|
| 22 |
+
"icons": [
|
| 23 |
+
{
|
| 24 |
+
"src": "icons/icon-96.png",
|
| 25 |
+
"sizes": "96x96",
|
| 26 |
+
"type": "image/png",
|
| 27 |
+
"purpose": "any"
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"src": "icons/icon-128.png",
|
| 31 |
+
"sizes": "128x128",
|
| 32 |
+
"type": "image/png",
|
| 33 |
+
"purpose": "any"
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
"src": "icons/icon-192.png",
|
| 37 |
+
"sizes": "192x192",
|
| 38 |
+
"type": "image/png",
|
| 39 |
+
"purpose": "any"
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"src": "icons/icon-512.png",
|
| 43 |
+
"sizes": "512x512",
|
| 44 |
+
"type": "image/png",
|
| 45 |
+
"purpose": "any"
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"src": "icons/icon-maskable-512.png",
|
| 49 |
+
"sizes": "512x512",
|
| 50 |
+
"type": "image/png",
|
| 51 |
+
"purpose": "maskable"
|
| 52 |
+
}
|
| 53 |
+
],
|
| 54 |
+
"shortcuts": [
|
| 55 |
+
{
|
| 56 |
+
"name": "Search",
|
| 57 |
+
"short_name": "Search",
|
| 58 |
+
"url": "/#/search",
|
| 59 |
+
"icons": [
|
| 60 |
+
{
|
| 61 |
+
"src": "icons/icon-96.png",
|
| 62 |
+
"sizes": "96x96"
|
| 63 |
+
}
|
| 64 |
+
]
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
"name": "Trending",
|
| 68 |
+
"short_name": "Trending",
|
| 69 |
+
"url": "/#/trending",
|
| 70 |
+
"icons": [
|
| 71 |
+
{
|
| 72 |
+
"src": "icons/icon-96.png",
|
| 73 |
+
"sizes": "96x96"
|
| 74 |
+
}
|
| 75 |
+
]
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"name": "Genres & moods",
|
| 79 |
+
"short_name": "Genres",
|
| 80 |
+
"url": "/#/browse",
|
| 81 |
+
"icons": [
|
| 82 |
+
{
|
| 83 |
+
"src": "icons/icon-96.png",
|
| 84 |
+
"sizes": "96x96"
|
| 85 |
+
}
|
| 86 |
+
]
|
| 87 |
+
}
|
| 88 |
+
],
|
| 89 |
+
"id": "/"
|
| 90 |
+
}
|
static/pwa/sw.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Cadence service worker — makes the app installable and usable offline.
|
| 2 |
+
|
| 3 |
+
Works for both deployments:
|
| 4 |
+
• served by the Flask backend at / (icons under /static/pwa/icons/)
|
| 5 |
+
• served as static files at ./index.html (icons under ./icons/)
|
| 6 |
+
Instead of hard-coding asset paths, it reads them from the manifest, and it caches
|
| 7 |
+
each entry independently so one missing file can never break the installation.
|
| 8 |
+
*/
|
| 9 |
+
const VERSION = 'cadence-v3';
|
| 10 |
+
/* One canonical key for the app shell. We must NOT serve a redirected response for a
|
| 11 |
+
navigation (browsers reject that), and on the Flask deployment /index.html redirects
|
| 12 |
+
to / — so the shell is fetched once and stored under this synthetic URL. */
|
| 13 |
+
const SHELL = new URL('__shell', self.location.href).href;
|
| 14 |
+
const CORE = ['./', './manifest.webmanifest'];
|
| 15 |
+
|
| 16 |
+
async function cacheShell() {
|
| 17 |
+
const cache = await caches.open(VERSION);
|
| 18 |
+
|
| 19 |
+
// 1. the app itself, stored under a stable key (no redirects involved)
|
| 20 |
+
try {
|
| 21 |
+
const res = await fetch('./', { cache: 'reload' });
|
| 22 |
+
if (res.ok) await cache.put(SHELL, res);
|
| 23 |
+
} catch { /* offline at install time — a later navigation will fill it */ }
|
| 24 |
+
|
| 25 |
+
// 2. supporting files, best effort
|
| 26 |
+
await Promise.allSettled(CORE.map(url => cache.add(new Request(url, { cache: 'reload' }))));
|
| 27 |
+
|
| 28 |
+
// 2. icons come from the manifest, so the same worker serves every hosting model
|
| 29 |
+
try {
|
| 30 |
+
const res = await fetch('./manifest.webmanifest', { cache: 'reload' });
|
| 31 |
+
const manifest = await res.json();
|
| 32 |
+
const icons = [
|
| 33 |
+
...(manifest.icons || []).map(i => i.src),
|
| 34 |
+
...(manifest.shortcuts || []).flatMap(s => (s.icons || []).map(i => i.src)),
|
| 35 |
+
'icons/apple-touch-icon.png',
|
| 36 |
+
].filter(Boolean);
|
| 37 |
+
await Promise.allSettled([...new Set(icons)].map(src => cache.add(new Request(src, { cache: 'reload' }))));
|
| 38 |
+
} catch {
|
| 39 |
+
/* icons are cosmetic — offline still works without them */
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
self.addEventListener('install', event => {
|
| 44 |
+
event.waitUntil(cacheShell().then(() => self.skipWaiting()).catch(() => self.skipWaiting()));
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
self.addEventListener('activate', event => {
|
| 48 |
+
event.waitUntil(
|
| 49 |
+
caches.keys()
|
| 50 |
+
.then(keys => Promise.all(keys.filter(k => k !== VERSION).map(k => caches.delete(k))))
|
| 51 |
+
.then(() => self.clients.claim())
|
| 52 |
+
);
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
self.addEventListener('fetch', event => {
|
| 56 |
+
const req = event.request;
|
| 57 |
+
if (req.method !== 'GET') return;
|
| 58 |
+
|
| 59 |
+
const url = new URL(req.url);
|
| 60 |
+
|
| 61 |
+
// never intercept the backend API, audio streaming, or other origins
|
| 62 |
+
if (url.origin !== self.location.origin) return;
|
| 63 |
+
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/sw.js')) return;
|
| 64 |
+
|
| 65 |
+
// pages: network first (so updates land), cache as the offline fallback
|
| 66 |
+
if (req.mode === 'navigate') {
|
| 67 |
+
event.respondWith(
|
| 68 |
+
fetch(req)
|
| 69 |
+
.then(res => {
|
| 70 |
+
if (res && res.ok) {
|
| 71 |
+
const copy = res.clone();
|
| 72 |
+
caches.open(VERSION).then(c => c.put(SHELL, copy)).catch(() => {});
|
| 73 |
+
}
|
| 74 |
+
return res;
|
| 75 |
+
})
|
| 76 |
+
.catch(async () => (await caches.match(SHELL)) || (await caches.match('./')) || Response.error())
|
| 77 |
+
);
|
| 78 |
+
return;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// assets: cache first, refresh in the background
|
| 82 |
+
event.respondWith(
|
| 83 |
+
caches.match(req).then(hit => {
|
| 84 |
+
if (hit) {
|
| 85 |
+
fetch(req).then(res => {
|
| 86 |
+
if (res && res.ok) caches.open(VERSION).then(c => c.put(req, res.clone())).catch(() => {});
|
| 87 |
+
}).catch(() => {});
|
| 88 |
+
return hit;
|
| 89 |
+
}
|
| 90 |
+
return fetch(req)
|
| 91 |
+
.then(res => {
|
| 92 |
+
if (res && res.ok && url.origin === self.location.origin) {
|
| 93 |
+
const copy = res.clone();
|
| 94 |
+
caches.open(VERSION).then(c => c.put(req, copy)).catch(() => {});
|
| 95 |
+
}
|
| 96 |
+
return res;
|
| 97 |
+
})
|
| 98 |
+
.catch(async () => (await caches.match(SHELL)) || (await caches.match('./')) || Response.error());
|
| 99 |
+
})
|
| 100 |
+
);
|
| 101 |
+
});
|
static/style.css
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ── Cadence ── dark, glassy music player ─────────────────────────────── */
|
| 2 |
+
:root{
|
| 3 |
+
--bg:#08080d;
|
| 4 |
+
--panel:rgba(20,20,30,.72);
|
| 5 |
+
--card:rgba(255,255,255,.045);
|
| 6 |
+
--card-hover:rgba(255,255,255,.085);
|
| 7 |
+
--line:rgba(255,255,255,.09);
|
| 8 |
+
--text:#eef0f7;
|
| 9 |
+
--muted:#969db4;
|
| 10 |
+
--dim:#6b7188;
|
| 11 |
+
--accent:#8b5cf6;
|
| 12 |
+
--accent-2:#22d3ee;
|
| 13 |
+
--accent-soft:rgba(139,92,246,.16);
|
| 14 |
+
--radius:16px;
|
| 15 |
+
--shadow:0 18px 50px -18px rgba(0,0,0,.85);
|
| 16 |
+
--player-h:96px;
|
| 17 |
+
}
|
| 18 |
+
*{box-sizing:border-box}
|
| 19 |
+
html,body{height:100%}
|
| 20 |
+
body{
|
| 21 |
+
margin:0;background:var(--bg);color:var(--text);
|
| 22 |
+
font:15px/1.45 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
| 23 |
+
-webkit-font-smoothing:antialiased;overflow:hidden;
|
| 24 |
+
}
|
| 25 |
+
body::before,body::after{
|
| 26 |
+
content:"";position:fixed;inset:auto;border-radius:50%;filter:blur(90px);pointer-events:none;z-index:0;
|
| 27 |
+
}
|
| 28 |
+
body::before{width:52vw;height:52vw;left:-14vw;top:-18vw;background:radial-gradient(circle at 30% 30%,rgba(139,92,246,.42),transparent 62%)}
|
| 29 |
+
body::after{width:46vw;height:46vw;right:-12vw;bottom:-16vw;background:radial-gradient(circle at 60% 60%,rgba(34,211,238,.30),transparent 62%)}
|
| 30 |
+
svg{width:20px;height:20px;fill:currentColor;flex:0 0 auto}
|
| 31 |
+
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
| 32 |
+
input[type=range]{appearance:none;-webkit-appearance:none;background:transparent;margin:0;cursor:pointer}
|
| 33 |
+
::-webkit-scrollbar{width:10px;height:10px}
|
| 34 |
+
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:99px;border:3px solid transparent;background-clip:content-box}
|
| 35 |
+
::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.26);background-clip:content-box}
|
| 36 |
+
|
| 37 |
+
.app{position:relative;z-index:1;height:100dvh;display:grid;grid-template-rows:auto minmax(0,1fr) auto}
|
| 38 |
+
|
| 39 |
+
/* ── top bar ── */
|
| 40 |
+
.topbar{
|
| 41 |
+
display:flex;align-items:center;gap:18px;padding:14px 22px;
|
| 42 |
+
border-bottom:1px solid var(--line);backdrop-filter:blur(18px);
|
| 43 |
+
background:linear-gradient(180deg,rgba(12,12,20,.85),rgba(10,10,16,.6));
|
| 44 |
+
}
|
| 45 |
+
.brand{display:flex;align-items:center;gap:11px;text-decoration:none;color:inherit;flex:0 0 auto}
|
| 46 |
+
.brand-mark{display:flex;align-items:flex-end;gap:3px;height:24px}
|
| 47 |
+
.brand-mark span{width:4px;border-radius:3px;background:linear-gradient(180deg,var(--accent-2),var(--accent));animation:eq 1.15s ease-in-out infinite}
|
| 48 |
+
.brand-mark span:nth-child(1){height:12px;animation-delay:-.35s}
|
| 49 |
+
.brand-mark span:nth-child(2){height:22px;animation-delay:-.7s}
|
| 50 |
+
.brand-mark span:nth-child(3){height:16px}
|
| 51 |
+
.brand-name{font-weight:700;font-size:19px;letter-spacing:.2px}
|
| 52 |
+
@keyframes eq{0%,100%{transform:scaleY(.45)}50%{transform:scaleY(1)}}
|
| 53 |
+
|
| 54 |
+
.searchwrap{position:relative;flex:1 1 auto;max-width:620px;display:flex;align-items:center;margin:0 auto}
|
| 55 |
+
.searchwrap .sicon{position:absolute;left:14px;color:var(--dim);width:18px;height:18px}
|
| 56 |
+
.searchwrap input{
|
| 57 |
+
width:100%;padding:12px 44px 12px 42px;border-radius:999px;border:1px solid var(--line);
|
| 58 |
+
background:rgba(255,255,255,.05);color:var(--text);outline:none;font-size:14.5px;
|
| 59 |
+
transition:border-color .15s,background .15s,box-shadow .15s;
|
| 60 |
+
}
|
| 61 |
+
.searchwrap input:focus{border-color:rgba(139,92,246,.65);background:rgba(255,255,255,.08);box-shadow:0 0 0 4px var(--accent-soft)}
|
| 62 |
+
.searchwrap input::placeholder{color:var(--dim)}
|
| 63 |
+
.searchwrap input::-webkit-search-cancel-button{filter:invert(.6)}
|
| 64 |
+
.searchwrap kbd{
|
| 65 |
+
position:absolute;right:12px;font-size:11px;color:var(--dim);border:1px solid var(--line);
|
| 66 |
+
border-radius:6px;padding:2px 7px;background:rgba(255,255,255,.04)
|
| 67 |
+
}
|
| 68 |
+
.searchwrap:focus-within kbd{opacity:0}
|
| 69 |
+
|
| 70 |
+
.suggest{
|
| 71 |
+
position:absolute;top:calc(100% + 8px);left:0;right:0;z-index:60;
|
| 72 |
+
background:rgba(18,18,28,.97);border:1px solid var(--line);border-radius:14px;
|
| 73 |
+
box-shadow:var(--shadow);overflow:hidden;backdrop-filter:blur(20px)
|
| 74 |
+
}
|
| 75 |
+
.suggest div{padding:10px 16px;font-size:14px;color:var(--muted);cursor:pointer;display:flex;gap:10px;align-items:center}
|
| 76 |
+
.suggest div svg{width:15px;height:15px;color:var(--dim)}
|
| 77 |
+
.suggest div:hover,.suggest div.active{background:rgba(139,92,246,.18);color:var(--text)}
|
| 78 |
+
|
| 79 |
+
.ghost-btn{
|
| 80 |
+
display:flex;align-items:center;gap:8px;padding:9px 15px;border-radius:999px;
|
| 81 |
+
border:1px solid var(--line);color:var(--muted);background:rgba(255,255,255,.03);font-size:13.5px;
|
| 82 |
+
transition:.15s;flex:0 0 auto
|
| 83 |
+
}
|
| 84 |
+
.ghost-btn:hover{color:var(--text);background:rgba(255,255,255,.08)}
|
| 85 |
+
.ghost-btn.active{color:var(--text);border-color:rgba(139,92,246,.5);background:var(--accent-soft)}
|
| 86 |
+
|
| 87 |
+
/* ── content / views ── */
|
| 88 |
+
.content{display:grid;grid-template-columns:minmax(0,1fr) 336px;min-height:0;transition:grid-template-columns .22s ease}
|
| 89 |
+
.content.queue-hidden{grid-template-columns:minmax(0,1fr) 0px}
|
| 90 |
+
.view{overflow-y:auto;padding:26px 28px 40px;min-height:0}
|
| 91 |
+
.queue-panel{
|
| 92 |
+
overflow:hidden;border-left:1px solid var(--line);background:var(--panel);
|
| 93 |
+
display:flex;flex-direction:column;min-height:0;backdrop-filter:blur(20px)
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.hero{max-width:760px;margin:4vh auto 0;text-align:center}
|
| 97 |
+
.hero h1{font-size:clamp(30px,4.4vw,50px);line-height:1.08;margin:0 0 14px;font-weight:800;letter-spacing:-.5px}
|
| 98 |
+
.hero h1 em{font-style:normal;background:linear-gradient(96deg,var(--accent-2),var(--accent) 60%,#f472b6);-webkit-background-clip:text;background-clip:text;color:transparent}
|
| 99 |
+
.hero p{color:var(--muted);margin:0 auto 26px;max-width:520px;font-size:15.5px}
|
| 100 |
+
.hero .cta{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
|
| 101 |
+
|
| 102 |
+
.section{margin:34px 0 0}
|
| 103 |
+
.section h2{font-size:15px;margin:0 0 14px;letter-spacing:.4px;text-transform:uppercase;color:var(--muted);font-weight:600;display:flex;align-items:center;gap:9px}
|
| 104 |
+
.section h2 svg{width:16px;height:16px;color:var(--accent)}
|
| 105 |
+
.chips{display:flex;flex-wrap:wrap;gap:9px}
|
| 106 |
+
.chip{
|
| 107 |
+
padding:8px 15px;border-radius:999px;border:1px solid var(--line);background:var(--card);
|
| 108 |
+
font-size:13.5px;color:var(--muted);transition:.15s;display:inline-flex;gap:8px;align-items:center
|
| 109 |
+
}
|
| 110 |
+
.chip:hover{color:var(--text);background:var(--card-hover);transform:translateY(-1px)}
|
| 111 |
+
.chip.strong{color:var(--text);border-color:rgba(139,92,246,.45);background:var(--accent-soft)}
|
| 112 |
+
.chip small{color:var(--dim)}
|
| 113 |
+
|
| 114 |
+
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px}
|
| 115 |
+
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:12px;cursor:pointer;transition:.16s;text-align:left;width:100%}
|
| 116 |
+
.card:hover{background:var(--card-hover);transform:translateY(-2px);border-color:rgba(139,92,246,.35)}
|
| 117 |
+
.card-art{aspect-ratio:1/1;border-radius:10px;overflow:hidden;margin-bottom:10px;background:linear-gradient(140deg,#22223a,#14141f);display:grid;place-items:center}
|
| 118 |
+
.card-art img{width:100%;height:100%;object-fit:cover;display:block}
|
| 119 |
+
.card-art svg{width:34px;height:34px;color:var(--dim)}
|
| 120 |
+
.card-title{font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 121 |
+
.card-sub{font-size:12px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
|
| 122 |
+
|
| 123 |
+
/* artist header */
|
| 124 |
+
.artist-head{display:flex;gap:22px;align-items:flex-end;padding:6px 0 22px;border-bottom:1px solid var(--line);margin-bottom:24px;flex-wrap:wrap}
|
| 125 |
+
.artist-head img{width:154px;height:154px;border-radius:50%;object-fit:cover;box-shadow:var(--shadow);background:#1a1a26}
|
| 126 |
+
.artist-head .ah-text{flex:1 1 320px;min-width:0}
|
| 127 |
+
.eyebrow{font-size:12px;letter-spacing:1.4px;text-transform:uppercase;color:var(--accent-2);font-weight:700}
|
| 128 |
+
.artist-head h1{font-size:clamp(28px,4vw,42px);margin:6px 0 8px;font-weight:800;letter-spacing:-.4px}
|
| 129 |
+
.artist-head .stats{color:var(--muted);font-size:13.5px;margin-bottom:14px}
|
| 130 |
+
.artist-head .desc{color:var(--dim);font-size:13px;max-width:640px;max-height:3.9em;overflow:hidden}
|
| 131 |
+
|
| 132 |
+
/* track list */
|
| 133 |
+
.tracks{display:flex;flex-direction:column;gap:2px;margin-top:4px}
|
| 134 |
+
.trow{
|
| 135 |
+
display:grid;grid-template-columns:34px 46px minmax(0,1fr) minmax(0,1.1fr) auto;align-items:center;gap:12px;
|
| 136 |
+
padding:7px 12px;border-radius:11px;cursor:pointer;transition:background .12s;position:relative
|
| 137 |
+
}
|
| 138 |
+
.trow:hover{background:var(--card-hover)}
|
| 139 |
+
.trow.now{background:var(--accent-soft);box-shadow:inset 0 0 0 1px rgba(139,92,246,.3)}
|
| 140 |
+
.trow .idx{color:var(--dim);font-size:13px;font-variant-numeric:tabular-nums;text-align:right;position:relative}
|
| 141 |
+
.trow .idx .playicon{position:absolute;inset:0;display:none;place-items:center}
|
| 142 |
+
.trow .idx .playicon svg{width:15px;height:15px;color:var(--text)}
|
| 143 |
+
.trow:hover .idx .num{display:none}
|
| 144 |
+
.trow:hover .idx .playicon{display:grid}
|
| 145 |
+
.trow .art{width:46px;height:46px;border-radius:8px;object-fit:cover;background:#1c1c28;display:block}
|
| 146 |
+
.trow .meta{min-width:0}
|
| 147 |
+
.trow .tt{font-size:14px;font-weight:550;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 148 |
+
.trow .ta{font-size:12.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
|
| 149 |
+
.trow .ta a{color:inherit;text-decoration:none}
|
| 150 |
+
.trow .ta a:hover{color:var(--accent-2);text-decoration:underline}
|
| 151 |
+
.trow .album{font-size:12.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 152 |
+
.trow .album a{color:inherit;text-decoration:none}
|
| 153 |
+
.trow .album a:hover{color:var(--accent-2);text-decoration:underline}
|
| 154 |
+
.trow .end{display:flex;align-items:center;gap:6px;color:var(--dim);font-size:12.5px}
|
| 155 |
+
.trow .addq{opacity:0;transition:.12s}
|
| 156 |
+
.trow:hover .addq{opacity:1}
|
| 157 |
+
.trow .nowbars{display:flex;align-items:flex-end;gap:2px;height:15px;width:15px}
|
| 158 |
+
.trow .nowbars i{width:3px;background:var(--accent-2);border-radius:2px;height:100%;animation:eq .9s ease-in-out infinite;transform-origin:bottom}
|
| 159 |
+
.trow .nowbars i:nth-child(2){animation-delay:-.3s}
|
| 160 |
+
.trow .nowbars i:nth-child(3){animation-delay:-.6s}
|
| 161 |
+
.playing .nowbars i{animation-play-state:running}
|
| 162 |
+
|
| 163 |
+
/* queue panel */
|
| 164 |
+
.queue-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding:20px 20px 14px;border-bottom:1px solid var(--line)}
|
| 165 |
+
.queue-head h2{font-size:15px;margin:0;font-weight:700}
|
| 166 |
+
.queue-head p{margin:4px 0 0;font-size:12px;color:var(--dim)}
|
| 167 |
+
.queue-list{list-style:none;margin:0;padding:10px;overflow-y:auto;min-height:0;flex:1}
|
| 168 |
+
.qrow{display:grid;grid-template-columns:38px minmax(0,1fr) auto;gap:10px;align-items:center;padding:7px 9px;border-radius:10px;cursor:pointer}
|
| 169 |
+
.qrow:hover{background:var(--card-hover)}
|
| 170 |
+
.qrow.now{background:var(--accent-soft)}
|
| 171 |
+
.qrow img{width:38px;height:38px;border-radius:7px;object-fit:cover;background:#1c1c28}
|
| 172 |
+
.qrow .qt{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 173 |
+
.qrow .qa{font-size:11.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 174 |
+
.qrow .qd{font-size:11.5px;color:var(--dim);font-variant-numeric:tabular-nums}
|
| 175 |
+
.queue-empty{padding:26px 20px;color:var(--dim);font-size:13px;text-align:center;line-height:1.7}
|
| 176 |
+
|
| 177 |
+
/* ── player bar ── */
|
| 178 |
+
.player{
|
| 179 |
+
display:grid;grid-template-columns:minmax(0,1fr) minmax(300px,1.5fr) minmax(0,1fr);align-items:center;gap:20px;
|
| 180 |
+
padding:12px 22px;border-top:1px solid var(--line);background:rgba(12,12,20,.86);backdrop-filter:blur(22px);min-height:var(--player-h)
|
| 181 |
+
}
|
| 182 |
+
.np{display:flex;align-items:center;gap:13px;min-width:0}
|
| 183 |
+
.np-art{
|
| 184 |
+
width:54px;height:54px;border-radius:10px;overflow:hidden;flex:0 0 auto;display:grid;place-items:center;
|
| 185 |
+
background:linear-gradient(140deg,#262640,#151522);border:1px solid var(--line)
|
| 186 |
+
}
|
| 187 |
+
.np-art img{width:100%;height:100%;object-fit:cover;display:block}
|
| 188 |
+
.np-art svg{color:var(--dim)}
|
| 189 |
+
.np-meta{min-width:0}
|
| 190 |
+
.np-title{font-size:14px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 191 |
+
.np-artist{font-size:12.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:3px}
|
| 192 |
+
.np-artist a{color:inherit;text-decoration:none}
|
| 193 |
+
.np-artist a:hover{color:var(--accent-2)}
|
| 194 |
+
.in-queue{margin-left:auto}
|
| 195 |
+
|
| 196 |
+
.controls{display:flex;flex-direction:column;gap:6px;min-width:0}
|
| 197 |
+
.buttons{display:flex;align-items:center;justify-content:center;gap:8px}
|
| 198 |
+
.icon-btn{width:38px;height:38px;border-radius:50%;display:grid;place-items:center;color:var(--muted);transition:.15s}
|
| 199 |
+
.icon-btn:hover{color:var(--text);background:rgba(255,255,255,.08)}
|
| 200 |
+
.icon-btn.on{color:var(--accent-2);background:var(--accent-soft)}
|
| 201 |
+
.icon-btn svg{width:19px;height:19px}
|
| 202 |
+
.play-btn{
|
| 203 |
+
width:47px;height:47px;border-radius:50%;display:grid;place-items:center;color:#0b0b12;
|
| 204 |
+
background:linear-gradient(140deg,#a78bfa,#22d3ee);box-shadow:0 8px 22px -8px rgba(139,92,246,.9);transition:.15s
|
| 205 |
+
}
|
| 206 |
+
.play-btn:hover{transform:scale(1.05)}
|
| 207 |
+
.play-btn svg{width:20px;height:20px}
|
| 208 |
+
.seekrow{display:flex;align-items:center;gap:11px}
|
| 209 |
+
.seekrow .t{font-size:11.5px;color:var(--dim);font-variant-numeric:tabular-nums;min-width:36px;text-align:center}
|
| 210 |
+
.seekwrap{position:relative;flex:1;height:16px;display:flex;align-items:center}
|
| 211 |
+
.seekwrap input[type=range]{width:100%;height:16px;position:relative;z-index:2}
|
| 212 |
+
.seekwrap input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:99px;background:rgba(255,255,255,.13)}
|
| 213 |
+
.seekwrap input[type=range]::-moz-range-track{height:4px;border-radius:99px;background:rgba(255,255,255,.13)}
|
| 214 |
+
.seekwrap input[type=range]::-webkit-slider-thumb{appearance:none;width:12px;height:12px;border-radius:50%;background:#fff;margin-top:-4px;opacity:0;transition:opacity .15s}
|
| 215 |
+
.seekwrap input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radius:50%;background:#fff;opacity:0}
|
| 216 |
+
.seekwrap:hover input[type=range]::-webkit-slider-thumb,.seekwrap input[type=range]:focus::-webkit-slider-thumb{opacity:1}
|
| 217 |
+
.seekwrap:hover input[type=range]::-moz-range-thumb{opacity:1}
|
| 218 |
+
.seekfill{position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);height:4px;border-radius:99px;pointer-events:none;overflow:hidden}
|
| 219 |
+
.seekfill span{display:block;height:100%;width:0;border-radius:99px;background:linear-gradient(90deg,var(--accent),var(--accent-2))}
|
| 220 |
+
|
| 221 |
+
.right{display:flex;align-items:center;justify-content:flex-end;gap:10px}
|
| 222 |
+
.vol{width:92px;height:16px}
|
| 223 |
+
.vol::-webkit-slider-runnable-track{height:4px;border-radius:99px;background:rgba(255,255,255,.13)}
|
| 224 |
+
.vol::-moz-range-track{height:4px;border-radius:99px;background:rgba(255,255,255,.13)}
|
| 225 |
+
.vol::-webkit-slider-thumb{appearance:none;width:12px;height:12px;border-radius:50%;background:var(--accent-2);margin-top:-4px}
|
| 226 |
+
.vol::-moz-range-thumb{width:12px;height:12px;border:0;border-radius:50%;background:var(--accent-2)}
|
| 227 |
+
.badge{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;color:var(--accent-2);border:1px solid rgba(34,211,238,.35);background:rgba(34,211,238,.1);padding:4px 10px;border-radius:99px}
|
| 228 |
+
.badge svg{width:13px;height:13px}
|
| 229 |
+
|
| 230 |
+
.spinner{display:grid;place-items:center;width:30px;height:30px}
|
| 231 |
+
.spinner span{width:19px;height:19px;border-radius:50%;border:2px solid rgba(255,255,255,.18);border-top-color:var(--accent-2);animation:spin .8s linear infinite}
|
| 232 |
+
@keyframes spin{to{transform:rotate(360deg)}}
|
| 233 |
+
|
| 234 |
+
/* toasts */
|
| 235 |
+
.toasts{position:fixed;bottom:calc(var(--player-h) + 18px);left:50%;transform:translateX(-50%);z-index:99;display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none}
|
| 236 |
+
.toast{
|
| 237 |
+
background:rgba(26,26,38,.97);border:1px solid var(--line);color:var(--text);padding:11px 18px;border-radius:12px;
|
| 238 |
+
font-size:13.5px;box-shadow:var(--shadow);animation:pop .22s ease;max-width:420px;text-align:center
|
| 239 |
+
}
|
| 240 |
+
.toast.err{border-color:rgba(244,63,94,.45)}
|
| 241 |
+
@keyframes pop{from{opacity:0;transform:translateY(8px) scale(.97)}}
|
| 242 |
+
|
| 243 |
+
.empty{padding:60px 10px;text-align:center;color:var(--dim)}
|
| 244 |
+
.empty h3{color:var(--muted);margin:0 0 8px;font-weight:600}
|
| 245 |
+
.skeleton{height:60px;border-radius:11px;background:linear-gradient(90deg,rgba(255,255,255,.04),rgba(255,255,255,.09),rgba(255,255,255,.04));background-size:200% 100%;animation:sk 1.2s linear infinite;margin-bottom:6px}
|
| 246 |
+
@keyframes sk{to{background-position:-200% 0}}
|
| 247 |
+
|
| 248 |
+
@media (max-width:900px){
|
| 249 |
+
.content{grid-template-columns:minmax(0,1fr)}
|
| 250 |
+
.queue-panel{position:fixed;top:0;right:0;bottom:var(--player-h);width:min(88vw,340px);z-index:80;transform:translateX(102%);transition:transform .24s ease;box-shadow:var(--shadow)}
|
| 251 |
+
.queue-panel.open{transform:none}
|
| 252 |
+
.player{grid-template-columns:1fr auto;grid-template-areas:"np right" "controls controls";gap:10px;padding:10px 14px}
|
| 253 |
+
.np{grid-area:np}.controls{grid-area:controls}.right{grid-area:right}
|
| 254 |
+
.vol{display:none}
|
| 255 |
+
.view{padding:18px 14px 30px}
|
| 256 |
+
.trow{grid-template-columns:26px 42px minmax(0,1fr) auto;gap:10px}
|
| 257 |
+
.trow .album{display:none}
|
| 258 |
+
.searchwrap kbd{display:none}
|
| 259 |
+
.brand-name{display:none}
|
| 260 |
+
}
|
templates/dashboard.html
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
| 6 |
+
<title>Cadence — music dashboard</title>
|
| 7 |
+
<link rel="manifest" href="/manifest.webmanifest">
|
| 8 |
+
<meta name="theme-color" content="#08080d">
|
| 9 |
+
<meta name="mobile-web-app-capable" content="yes">
|
| 10 |
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
| 11 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
| 12 |
+
<meta name="apple-mobile-web-app-title" content="Cadence">
|
| 13 |
+
<meta name="application-name" content="Cadence">
|
| 14 |
+
<meta name="description" content="Search YouTube Music, build radios from any song, keep the queue growing.">
|
| 15 |
+
<link rel="icon" href="/static/pwa/icons/favicon-64.png" sizes="64x64">
|
| 16 |
+
<link rel="apple-touch-icon" href="/static/pwa/icons/apple-touch-icon.png">
|
| 17 |
+
<link rel="stylesheet" href="/static/dash.css">
|
| 18 |
+
</head>
|
| 19 |
+
<body>
|
| 20 |
+
|
| 21 |
+
<svg style="display:none" aria-hidden="true">
|
| 22 |
+
<symbol id="i-play" viewBox="0 0 24 24"><path d="M8 5.14v13.72c0 .8.87 1.3 1.56.9l11-6.86a1.05 1.05 0 0 0 0-1.8l-11-6.86c-.69-.4-1.56.1-1.56.9Z"/></symbol>
|
| 23 |
+
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M7 4h3.2v16H7zM13.8 4H17v16h-3.2z"/></symbol>
|
| 24 |
+
<symbol id="i-next" viewBox="0 0 24 24"><path d="M6 5.4v13.2c0 .77.85 1.24 1.5.84l9.6-6.6a1 1 0 0 0 0-1.68l-9.6-6.6c-.65-.4-1.5.07-1.5.84ZM18.4 4.6h2.2v14.8h-2.2z"/></symbol>
|
| 25 |
+
<symbol id="i-prev" viewBox="0 0 24 24"><path d="M18 5.4v13.2c0 .77-.85 1.24-1.5.84l-9.6-6.6a1 1 0 0 1 0-1.68l9.6-6.6c.65-.4 1.5.07 1.5.84ZM5.6 4.6h-2.2v14.8h2.2z"/></symbol>
|
| 26 |
+
<symbol id="i-shuffle" viewBox="0 0 24 24"><path d="M17.6 4.1a1 1 0 0 0-1.1 1.1v.6h-2.3c-.4 0-.8.16-1.06.45l-1.2 1.3a1 1 0 0 0 1.47 1.36l1-1.1h2.1v.65a1 1 0 0 0 1.72.7l2.6-2.6a1 1 0 0 0 0-1.42l-2.6-2.6a1 1 0 0 0-.63-.44Zm.63 10.06h-2.1l-1-1.1a1 1 0 0 0-1.48 1.35l1.2 1.3c.27.3.66.46 1.06.46h2.3v.6a1 1 0 0 0 1.72.7l2.6-2.6a1 1 0 0 0 0-1.42l-2.6-2.6a1 1 0 0 0-1.72.7ZM4.3 6.4h2.9c.4 0 .79.16 1.06.45l7.4 8.05c.27.29.66.45 1.06.45h.9a1 1 0 1 0 0-2h-.5l-7.4-8.05A3 3 0 0 0 7.2 4.4H4.3a1 1 0 0 0 0 2Zm0 11.2h2.9a3 3 0 0 0 2.24-1c.5-.55.55-1.4.04-1.98a1 1 0 0 0-1.45-.08 1 1 0 0 1-.72.3h-3a1 1 0 0 0 0 2Z"/></symbol>
|
| 27 |
+
<symbol id="i-repeat" viewBox="0 0 24 24"><path d="M7 4.3h8.6a4.4 4.4 0 0 1 4.4 4.4V11a1 1 0 1 1-2 0V8.7a2.4 2.4 0 0 0-2.4-2.4H7v1.55c0 .66-.8 1-1.26.53L3.3 6.14a.75.75 0 0 1 0-1.06l2.44-2.44c.46-.46 1.26-.13 1.26.53Zm10 15.4H8.4A4.4 4.4 0 0 1 4 15.3V13a1 1 0 1 1 2 0v2.3a2.4 2.4 0 0 0 2.4 2.4H17v-1.55c0-.66.8-1 1.26-.53l2.44 2.44c.3.3.3.77 0 1.06l-2.44 2.44c-.46.46-1.26.13-1.26-.53Z"/></symbol>
|
| 28 |
+
<symbol id="i-repeat1" viewBox="0 0 24 24"><path d="M7 4.3h8.6a4.4 4.4 0 0 1 4.4 4.4V11a1 1 0 1 1-2 0V8.7a2.4 2.4 0 0 0-2.4-2.4H7v1.55c0 .66-.8 1-1.26.53L3.3 6.14a.75.75 0 0 1 0-1.06l2.44-2.44c.46-.46 1.26-.13 1.26.53Zm10 15.4H8.4A4.4 4.4 0 0 1 4 15.3V13a1 1 0 1 1 2 0v2.3a2.4 2.4 0 0 0 2.4 2.4H17v-1.55c0-.66.8-1 1.26-.53l2.44 2.44c.3.3.3.77 0 1.06l-2.44 2.44c-.46.46-1.26.13-1.26-.53Z"/><path d="M12.9 9.4h-1.2v-.95l-1.5.46v-1.1l2.5-.83h.2Zm-1.6 4.2c-.9 0-1.6-.3-2.1-.9s-.75-1.4-.75-2.4v-.7c0-.6.1-1.1.3-1.6.2-.45.5-.8.9-1.05.4-.24.9-.36 1.5-.36s1.1.12 1.55.36c.4.24.7.6.9 1.05.2.46.3.98.3 1.57v.73c0 .6-.1 1.13-.3 1.6-.2.46-.5.81-.9 1.06-.4.25-.9.37-1.5.37Zm-.1-1.2c.4 0 .7-.14.9-.42.2-.3.3-.75.3-1.36v-1c0-.6-.1-1.05-.3-1.34-.2-.28-.5-.42-.9-.42s-.7.14-.9.42c-.2.3-.3.74-.3 1.35v1c0 .6.1 1.06.3 1.35.2.28.5.42.9.42Z" transform="translate(1.6 1.2)"/></symbol>
|
| 29 |
+
<symbol id="i-vol" viewBox="0 0 24 24"><path d="M13 4.6 8.6 8H5.4A1.4 1.4 0 0 0 4 9.4v5.2A1.4 1.4 0 0 0 5.4 16h3.2l4.4 3.4c.66.51 1.6.04 1.6-.79V5.39c0-.83-.94-1.3-1.6-.79Zm3.7 3.1a1 1 0 0 0-.09 1.41 4.3 4.3 0 0 1 0 5.78 1 1 0 1 0 1.5 1.32 6.3 6.3 0 0 0 0-8.42 1 1 0 0 0-1.41-.09Zm2.6-2.75a1 1 0 1 0-1.35 1.47 8.1 8.1 0 0 1 0 11.16 1 1 0 0 0 1.35 1.47 10.1 10.1 0 0 0 0-14.1Z"/></symbol>
|
| 30 |
+
<symbol id="i-mute" viewBox="0 0 24 24"><path d="M13 4.6 8.6 8H5.4A1.4 1.4 0 0 0 4 9.4v5.2A1.4 1.4 0 0 0 5.4 16h3.2l4.4 3.4c.66.51 1.6.04 1.6-.79V5.39c0-.83-.94-1.3-1.6-.79Zm4.3 5.1-1.7 1.7-1.7-1.7a1 1 0 0 0-1.42 1.42l1.7 1.7-1.7 1.7a1 1 0 0 0 1.42 1.41l1.7-1.7 1.7 1.7a1 1 0 0 0 1.41-1.41l-1.7-1.7 1.7-1.7a1 1 0 1 0-1.41-1.42Z"/></symbol>
|
| 31 |
+
<symbol id="i-queue" viewBox="0 0 24 24"><path d="M4 6a1 1 0 0 1 1-1h9a1 1 0 1 1 0 2H5a1 1 0 0 1-1-1Zm0 5a1 1 0 0 1 1-1h9a1 1 0 1 1 0 2H5a1 1 0 0 1-1-1Zm0 5a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H5a1 1 0 0 1-1-1Zm19.1-2.1a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm-1.6-4.4c.55 0 1 .45 1 1v7.4a3.6 3.6 0 0 1-3.6 3.6 1 1 0 0 1 0-2 1.6 1.6 0 0 0 1.6-1.6V10a1 1 0 0 1 1-1Z"/></symbol>
|
| 32 |
+
<symbol id="i-search" viewBox="0 0 24 24"><path d="M10.5 3a7.5 7.5 0 1 0 4.55 13.46l3.24 3.25a1.1 1.1 0 0 0 1.57-1.56l-3.25-3.24A7.5 7.5 0 0 0 10.5 3Zm0 2.2a5.3 5.3 0 1 1 0 10.6 5.3 5.3 0 0 1 0-10.6Z"/></symbol>
|
| 33 |
+
<symbol id="i-spark" viewBox="0 0 24 24"><path d="M12 2.4c.3 0 .55.2.63.48l.86 3.2a4.4 4.4 0 0 0 3.13 3.13l3.2.86a.66.66 0 0 1 0 1.26l-3.2.86a4.4 4.4 0 0 0-3.13 3.13l-.86 3.2a.66.66 0 0 1-1.26 0l-.86-3.2a4.4 4.4 0 0 0-3.13-3.13l-3.2-.86a.66.66 0 0 1 0-1.26l3.2-.86A4.4 4.4 0 0 0 10.51 6.08l.86-3.2A.66.66 0 0 1 12 2.4Zm6.4 12.1c.17 0 .32.11.37.28l.4 1.44c.2.72.76 1.28 1.48 1.48l1.44.4a.38.38 0 0 1 0 .73l-1.44.4a2.1 2.1 0 0 0-1.48 1.48l-.4 1.44a.38.38 0 0 1-.73 0l-.4-1.44a2.1 2.1 0 0 0-1.48-1.48l-1.44-.4a.38.38 0 0 1 0-.73l1.44-.4a2.1 2.1 0 0 0 1.48-1.48l.4-1.44c.05-.17.2-.28.37-.28Z"/></symbol>
|
| 34 |
+
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M11 5a1 1 0 1 1 2 0v6h6a1 1 0 1 1 0 2h-6v6a1 1 0 1 1-2 0v-6H5a1 1 0 1 1 0-2h6Z"/></symbol>
|
| 35 |
+
<symbol id="i-close" viewBox="0 0 24 24"><path d="M6.4 5a1 1 0 0 1 1.42 0L12 9.17 16.18 5A1 1 0 1 1 17.6 6.41L13.41 10.6l4.18 4.18a1 1 0 0 1-1.41 1.41L12 12.01l-4.18 4.18A1 1 0 0 1 6.4 14.78l4.18-4.18L6.4 6.41A1 1 0 0 1 6.4 5Z"/></symbol>
|
| 36 |
+
<symbol id="i-artist" viewBox="0 0 24 24"><path d="M12 11.5a4.25 4.25 0 1 0 0-8.5 4.25 4.25 0 0 0 0 8.5Zm0 2c-4.42 0-8 2.46-8 5.5 0 1.1.9 2 2 2h12a2 2 0 0 0 2-2c0-3.04-3.58-5.5-8-5.5Z"/></symbol>
|
| 37 |
+
</svg>
|
| 38 |
+
|
| 39 |
+
<div class="app">
|
| 40 |
+
<header class="topbar">
|
| 41 |
+
<a class="brand" href="#" id="brandLink">
|
| 42 |
+
<span class="brand-mark"><span></span><span></span><span></span></span>
|
| 43 |
+
<span class="brand-name">Cadence</span>
|
| 44 |
+
<span class="brand-tag">dashboard</span>
|
| 45 |
+
</a>
|
| 46 |
+
|
| 47 |
+
<div class="searchwrap">
|
| 48 |
+
<svg class="sicon"><use href="#i-search"/></svg>
|
| 49 |
+
<input id="q" type="search" placeholder="Search songs, artists, albums…" autocomplete="off" spellcheck="false" aria-label="Search">
|
| 50 |
+
<kbd>/</kbd>
|
| 51 |
+
<div id="suggest" class="suggest" hidden></div>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<div class="topbar-right">
|
| 55 |
+
<select id="countrySel" class="ghost-select" title="Trending region" aria-label="Trending region"></select>
|
| 56 |
+
<button class="ghost-btn" id="queueToggle" title="Queue (Q)"><svg><use href="#i-queue"/></svg><span>Queue</span></button>
|
| 57 |
+
</div>
|
| 58 |
+
</header>
|
| 59 |
+
|
| 60 |
+
<div class="body">
|
| 61 |
+
<nav class="sidebar" id="sidebar">
|
| 62 |
+
<div class="nav-label">Discover</div>
|
| 63 |
+
<button class="nav-item" data-nav="overview"><svg><use href="#i-spark"/></svg><span>Overview</span></button>
|
| 64 |
+
<button class="nav-item" data-nav="search"><svg><use href="#i-search"/></svg><span>Search</span></button>
|
| 65 |
+
<button class="nav-item" data-nav="trending"><svg><use href="#i-play"/></svg><span>Trending</span></button>
|
| 66 |
+
<button class="nav-item" data-nav="browse"><svg><use href="#i-queue"/></svg><span>Genres & moods</span></button>
|
| 67 |
+
<button class="nav-item" data-nav="recent"><svg><use href="#i-artist"/></svg><span>Recently played</span></button>
|
| 68 |
+
|
| 69 |
+
<div class="nav-label" style="margin-top:16px">Engine</div>
|
| 70 |
+
<div class="engine" id="engine">
|
| 71 |
+
<div class="engine-row"><span>Stream cache</span><b id="enCache">–</b></div>
|
| 72 |
+
<div class="engine-row"><span>Tracks cached</span><b id="enTracks">–</b></div>
|
| 73 |
+
<div class="engine-row"><span>Downloads</span><b id="enDownloads">–</b></div>
|
| 74 |
+
<div class="engine-row"><span>Avg fetch</span><b id="enAvg">–</b></div>
|
| 75 |
+
<div class="engine-row"><span>In flight</span><b id="enInflight">–</b></div>
|
| 76 |
+
<div class="engine-row"><span>Uptime</span><b id="enUptime">–</b></div>
|
| 77 |
+
<div class="engine-bar" title="Cache usage"><span id="enBar"></span></div>
|
| 78 |
+
</div>
|
| 79 |
+
</nav>
|
| 80 |
+
|
| 81 |
+
<main class="main" id="main"></main>
|
| 82 |
+
|
| 83 |
+
<aside class="rail" id="rail">
|
| 84 |
+
<div class="rail-head">
|
| 85 |
+
<div>
|
| 86 |
+
<h2 id="queueTitle">Up next</h2>
|
| 87 |
+
<p id="queueSub">Suggestions appear as you listen</p>
|
| 88 |
+
</div>
|
| 89 |
+
<button class="icon-btn" id="clearQueue" title="Clear queue"><svg><use href="#i-close"/></svg></button>
|
| 90 |
+
</div>
|
| 91 |
+
<div class="rail-body"><ol class="queue-list" id="queueList"></ol></div>
|
| 92 |
+
</aside>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<footer class="player" id="player">
|
| 96 |
+
<div class="np">
|
| 97 |
+
<div class="np-art" id="npArt"><svg><use href="#i-artist"/></svg></div>
|
| 98 |
+
<div class="np-meta">
|
| 99 |
+
<div class="np-title" id="npTitle">Nothing playing</div>
|
| 100 |
+
<div class="np-artist" id="npArtist">Search for a song to begin</div>
|
| 101 |
+
</div>
|
| 102 |
+
<button class="icon-btn in-queue" id="addCurrent" title="Add to queue" hidden><svg><use href="#i-plus"/></svg></button>
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
<div class="controls">
|
| 106 |
+
<div class="buttons">
|
| 107 |
+
<button class="icon-btn" id="shuffleBtn" title="Shuffle (S)"><svg><use href="#i-shuffle"/></svg></button>
|
| 108 |
+
<button class="icon-btn" id="prevBtn" title="Previous (P)"><svg><use href="#i-prev"/></svg></button>
|
| 109 |
+
<button class="play-btn" id="playBtn" title="Play / pause (Space)"><svg><use href="#i-play"/></svg></button>
|
| 110 |
+
<button class="icon-btn" id="nextBtn" title="Next (N)"><svg><use href="#i-next"/></svg></button>
|
| 111 |
+
<button class="icon-btn" id="repeatBtn" title="Repeat (R)"><svg><use href="#i-repeat"/></svg></button>
|
| 112 |
+
</div>
|
| 113 |
+
<div class="seekrow">
|
| 114 |
+
<span class="t" id="tCur">0:00</span>
|
| 115 |
+
<div class="seekwrap">
|
| 116 |
+
<input type="range" id="seek" min="0" max="1000" value="0" step="1" aria-label="Seek">
|
| 117 |
+
<div class="seekfill" id="seekFill"><span></span></div>
|
| 118 |
+
</div>
|
| 119 |
+
<span class="t" id="tDur">0:00</span>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
<div class="right">
|
| 124 |
+
<div class="spinner" id="spinner" hidden><span></span></div>
|
| 125 |
+
<span class="badge" id="radioBadge" hidden><svg><use href="#i-spark"/></svg>Radio</span>
|
| 126 |
+
<button class="icon-btn" id="muteBtn" title="Mute (M)"><svg><use href="#i-vol"/></svg></button>
|
| 127 |
+
<input type="range" id="vol" class="vol" min="0" max="1" step="0.01" value="0.9" aria-label="Volume">
|
| 128 |
+
</div>
|
| 129 |
+
</footer>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
<audio id="audio" preload="auto" crossorigin="anonymous"></audio>
|
| 133 |
+
<div class="toasts" id="toasts"></div>
|
| 134 |
+
|
| 135 |
+
<script>
|
| 136 |
+
// registers on https (real deployments) and on localhost (local checks)
|
| 137 |
+
const swOK = location.protocol === 'https:' ||
|
| 138 |
+
['localhost', '127.0.0.1', '[::1]'].includes(location.hostname);
|
| 139 |
+
if ('serviceWorker' in navigator && swOK) {
|
| 140 |
+
window.addEventListener('load', () => {
|
| 141 |
+
navigator.serviceWorker.register('/sw.js', { scope: '/' })
|
| 142 |
+
.catch(err => console.warn('Service worker unavailable:', err.message));
|
| 143 |
+
});
|
| 144 |
+
}
|
| 145 |
+
</script>
|
| 146 |
+
<script src="/static/dash.js"></script>
|
| 147 |
+
</body>
|
| 148 |
+
</html>
|
templates/index.html
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Cadence — stream anything</title>
|
| 7 |
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎧</text></svg>">
|
| 8 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
|
| 12 |
+
<svg style="display:none" aria-hidden="true">
|
| 13 |
+
<symbol id="i-play" viewBox="0 0 24 24"><path d="M8 5.14v13.72c0 .8.87 1.3 1.56.9l11-6.86a1.05 1.05 0 0 0 0-1.8l-11-6.86c-.69-.4-1.56.1-1.56.9Z"/></symbol>
|
| 14 |
+
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M7 4h3.2v16H7zM13.8 4H17v16h-3.2z"/></symbol>
|
| 15 |
+
<symbol id="i-next" viewBox="0 0 24 24"><path d="M6 5.4v13.2c0 .77.85 1.24 1.5.84l9.6-6.6a1 1 0 0 0 0-1.68l-9.6-6.6c-.65-.4-1.5.07-1.5.84ZM18.4 4.6h2.2v14.8h-2.2z"/></symbol>
|
| 16 |
+
<symbol id="i-prev" viewBox="0 0 24 24"><path d="M18 5.4v13.2c0 .77-.85 1.24-1.5.84l-9.6-6.6a1 1 0 0 1 0-1.68l9.6-6.6c.65-.4 1.5.07 1.5.84ZM5.6 4.6h-2.2v14.8h2.2z"/></symbol>
|
| 17 |
+
<symbol id="i-shuffle" viewBox="0 0 24 24"><path d="M17.6 4.1a1 1 0 0 0-1.1 1.1v.6h-2.3c-.4 0-.8.16-1.06.45l-1.2 1.3a1 1 0 0 0 1.47 1.36l1-1.1h2.1v.65a1 1 0 0 0 1.72.7l2.6-2.6a1 1 0 0 0 0-1.42l-2.6-2.6a1 1 0 0 0-.63-.44Zm.63 10.06h-2.1l-1-1.1a1 1 0 0 0-1.48 1.35l1.2 1.3c.27.3.66.46 1.06.46h2.3v.6a1 1 0 0 0 1.72.7l2.6-2.6a1 1 0 0 0 0-1.42l-2.6-2.6a1 1 0 0 0-1.72.7ZM4.3 6.4h2.9c.4 0 .79.16 1.06.45l7.4 8.05c.27.29.66.45 1.06.45h.9a1 1 0 1 0 0-2h-.5l-7.4-8.05A3 3 0 0 0 7.2 4.4H4.3a1 1 0 0 0 0 2Zm0 11.2h2.9a3 3 0 0 0 2.24-1c.5-.55.55-1.4.04-1.98a1 1 0 0 0-1.45-.08 1 1 0 0 1-.72.3h-3a1 1 0 0 0 0 2Z"/></symbol>
|
| 18 |
+
<symbol id="i-repeat" viewBox="0 0 24 24"><path d="M7 4.3h8.6a4.4 4.4 0 0 1 4.4 4.4V11a1 1 0 1 1-2 0V8.7a2.4 2.4 0 0 0-2.4-2.4H7v1.55c0 .66-.8 1-1.26.53L3.3 6.14a.75.75 0 0 1 0-1.06l2.44-2.44c.46-.46 1.26-.13 1.26.53Zm10 15.4H8.4A4.4 4.4 0 0 1 4 15.3V13a1 1 0 1 1 2 0v2.3a2.4 2.4 0 0 0 2.4 2.4H17v-1.55c0-.66.8-1 1.26-.53l2.44 2.44c.3.3.3.77 0 1.06l-2.44 2.44c-.46.46-1.26.13-1.26-.53Z"/></symbol>
|
| 19 |
+
<symbol id="i-repeat1" viewBox="0 0 24 24"><path d="M7 4.3h8.6a4.4 4.4 0 0 1 4.4 4.4V11a1 1 0 1 1-2 0V8.7a2.4 2.4 0 0 0-2.4-2.4H7v1.55c0 .66-.8 1-1.26.53L3.3 6.14a.75.75 0 0 1 0-1.06l2.44-2.44c.46-.46 1.26-.13 1.26.53Zm10 15.4H8.4A4.4 4.4 0 0 1 4 15.3V13a1 1 0 1 1 2 0v2.3a2.4 2.4 0 0 0 2.4 2.4H17v-1.55c0-.66.8-1 1.26-.53l2.44 2.44c.3.3.3.77 0 1.06l-2.44 2.44c-.46.46-1.26.13-1.26-.53Z"/><path d="M12.9 9.4h-1.2v-.95l-1.5.46v-1.1l2.5-.83h.2Zm-1.6 4.2c-.9 0-1.6-.3-2.1-.9s-.75-1.4-.75-2.4v-.7c0-.6.1-1.1.3-1.6.2-.45.5-.8.9-1.05.4-.24.9-.36 1.5-.36s1.1.12 1.55.36c.4.24.7.6.9 1.05.2.46.3.98.3 1.57v.73c0 .6-.1 1.13-.3 1.6-.2.46-.5.81-.9 1.06-.4.25-.9.37-1.5.37Zm-.1-1.2c.4 0 .7-.14.9-.42.2-.3.3-.75.3-1.36v-1c0-.6-.1-1.05-.3-1.34-.2-.28-.5-.42-.9-.42s-.7.14-.9.42c-.2.3-.3.74-.3 1.35v1c0 .6.1 1.06.3 1.35.2.28.5.42.9.42Z" transform="translate(1.6 1.2)"/></symbol>
|
| 20 |
+
<symbol id="i-vol" viewBox="0 0 24 24"><path d="M13 4.6 8.6 8H5.4A1.4 1.4 0 0 0 4 9.4v5.2A1.4 1.4 0 0 0 5.4 16h3.2l4.4 3.4c.66.51 1.6.04 1.6-.79V5.39c0-.83-.94-1.3-1.6-.79Zm3.7 3.1a1 1 0 0 0-.09 1.41 4.3 4.3 0 0 1 0 5.78 1 1 0 1 0 1.5 1.32 6.3 6.3 0 0 0 0-8.42 1 1 0 0 0-1.41-.09Zm2.6-2.75a1 1 0 1 0-1.35 1.47 8.1 8.1 0 0 1 0 11.16 1 1 0 0 0 1.35 1.47 10.1 10.1 0 0 0 0-14.1Z"/></symbol>
|
| 21 |
+
<symbol id="i-mute" viewBox="0 0 24 24"><path d="M13 4.6 8.6 8H5.4A1.4 1.4 0 0 0 4 9.4v5.2A1.4 1.4 0 0 0 5.4 16h3.2l4.4 3.4c.66.51 1.6.04 1.6-.79V5.39c0-.83-.94-1.3-1.6-.79Zm4.3 5.1-1.7 1.7-1.7-1.7a1 1 0 0 0-1.42 1.42l1.7 1.7-1.7 1.7a1 1 0 0 0 1.42 1.41l1.7-1.7 1.7 1.7a1 1 0 0 0 1.41-1.41l-1.7-1.7 1.7-1.7a1 1 0 1 0-1.41-1.42Z"/></symbol>
|
| 22 |
+
<symbol id="i-queue" viewBox="0 0 24 24"><path d="M4 6a1 1 0 0 1 1-1h9a1 1 0 1 1 0 2H5a1 1 0 0 1-1-1Zm0 5a1 1 0 0 1 1-1h9a1 1 0 1 1 0 2H5a1 1 0 0 1-1-1Zm0 5a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H5a1 1 0 0 1-1-1Zm19.1-2.1a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm-1.6-4.4c.55 0 1 .45 1 1v7.4a3.6 3.6 0 0 1-3.6 3.6 1 1 0 0 1 0-2 1.6 1.6 0 0 0 1.6-1.6V10a1 1 0 0 1 1-1Z"/></symbol>
|
| 23 |
+
<symbol id="i-search" viewBox="0 0 24 24"><path d="M10.5 3a7.5 7.5 0 1 0 4.55 13.46l3.24 3.25a1.1 1.1 0 0 0 1.57-1.56l-3.25-3.24A7.5 7.5 0 0 0 10.5 3Zm0 2.2a5.3 5.3 0 1 1 0 10.6 5.3 5.3 0 0 1 0-10.6Z"/></symbol>
|
| 24 |
+
<symbol id="i-spark" viewBox="0 0 24 24"><path d="M12 2.4c.3 0 .55.2.63.48l.86 3.2a4.4 4.4 0 0 0 3.13 3.13l3.2.86a.66.66 0 0 1 0 1.26l-3.2.86a4.4 4.4 0 0 0-3.13 3.13l-.86 3.2a.66.66 0 0 1-1.26 0l-.86-3.2a4.4 4.4 0 0 0-3.13-3.13l-3.2-.86a.66.66 0 0 1 0-1.26l3.2-.86A4.4 4.4 0 0 0 10.51 6.08l.86-3.2A.66.66 0 0 1 12 2.4Zm6.4 12.1c.17 0 .32.11.37.28l.4 1.44c.2.72.76 1.28 1.48 1.48l1.44.4a.38.38 0 0 1 0 .73l-1.44.4a2.1 2.1 0 0 0-1.48 1.48l-.4 1.44a.38.38 0 0 1-.73 0l-.4-1.44a2.1 2.1 0 0 0-1.48-1.48l-1.44-.4a.38.38 0 0 1 0-.73l1.44-.4a2.1 2.1 0 0 0 1.48-1.48l.4-1.44c.05-.17.2-.28.37-.28Z"/></symbol>
|
| 25 |
+
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M11 5a1 1 0 1 1 2 0v6h6a1 1 0 1 1 0 2h-6v6a1 1 0 1 1-2 0v-6H5a1 1 0 1 1 0-2h6Z"/></symbol>
|
| 26 |
+
<symbol id="i-close" viewBox="0 0 24 24"><path d="M6.4 5a1 1 0 0 1 1.42 0L12 9.17 16.18 5A1 1 0 1 1 17.6 6.41L13.41 10.6l4.18 4.18a1 1 0 0 1-1.41 1.41L12 12.01l-4.18 4.18A1 1 0 0 1 6.4 14.78l4.18-4.18L6.4 6.41A1 1 0 0 1 6.4 5Z"/></symbol>
|
| 27 |
+
<symbol id="i-artist" viewBox="0 0 24 24"><path d="M12 11.5a4.25 4.25 0 1 0 0-8.5 4.25 4.25 0 0 0 0 8.5Zm0 2c-4.42 0-8 2.46-8 5.5 0 1.1.9 2 2 2h12a2 2 0 0 0 2-2c0-3.04-3.58-5.5-8-5.5Z"/></symbol>
|
| 28 |
+
</svg>
|
| 29 |
+
|
| 30 |
+
<div class="app">
|
| 31 |
+
<header class="topbar">
|
| 32 |
+
<a class="brand" href="#" id="brandLink">
|
| 33 |
+
<span class="brand-mark">
|
| 34 |
+
<span></span><span></span><span></span>
|
| 35 |
+
</span>
|
| 36 |
+
<span class="brand-name">Cadence</span>
|
| 37 |
+
</a>
|
| 38 |
+
|
| 39 |
+
<div class="searchwrap">
|
| 40 |
+
<svg class="sicon"><use href="#i-search"/></svg>
|
| 41 |
+
<input id="q" type="search" placeholder="Search songs, artists or albums…" autocomplete="off" spellcheck="false" aria-label="Search">
|
| 42 |
+
<kbd>/</kbd>
|
| 43 |
+
<div id="suggest" class="suggest" hidden></div>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<button class="ghost-btn" id="queueToggle" title="Queue (Q)">
|
| 47 |
+
<svg><use href="#i-queue"/></svg><span>Queue</span>
|
| 48 |
+
</button>
|
| 49 |
+
</header>
|
| 50 |
+
|
| 51 |
+
<main class="content">
|
| 52 |
+
<section class="view" id="view"></section>
|
| 53 |
+
|
| 54 |
+
<aside class="queue-panel" id="queuePanel">
|
| 55 |
+
<div class="queue-head">
|
| 56 |
+
<div>
|
| 57 |
+
<h2 id="queueTitle">Up next</h2>
|
| 58 |
+
<p id="queueSub">Suggestions appear as you listen</p>
|
| 59 |
+
</div>
|
| 60 |
+
<button class="icon-btn" id="clearQueue" title="Clear queue"><svg><use href="#i-close"/></svg></button>
|
| 61 |
+
</div>
|
| 62 |
+
<ol class="queue-list" id="queueList"></ol>
|
| 63 |
+
</aside>
|
| 64 |
+
</main>
|
| 65 |
+
|
| 66 |
+
<footer class="player" id="player">
|
| 67 |
+
<div class="np">
|
| 68 |
+
<div class="np-art" id="npArt"><svg><use href="#i-artist"/></svg></div>
|
| 69 |
+
<div class="np-meta">
|
| 70 |
+
<div class="np-title" id="npTitle">Nothing playing</div>
|
| 71 |
+
<div class="np-artist" id="npArtist">Search for a song to begin</div>
|
| 72 |
+
</div>
|
| 73 |
+
<button class="icon-btn in-queue" id="addCurrent" title="Add to queue" hidden><svg><use href="#i-plus"/></svg></button>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<div class="controls">
|
| 77 |
+
<div class="buttons">
|
| 78 |
+
<button class="icon-btn" id="shuffleBtn" title="Shuffle (S)"><svg><use href="#i-shuffle"/></svg></button>
|
| 79 |
+
<button class="icon-btn" id="prevBtn" title="Previous (P)"><svg><use href="#i-prev"/></svg></button>
|
| 80 |
+
<button class="play-btn" id="playBtn" title="Play / pause (Space)"><svg><use href="#i-play"/></svg></button>
|
| 81 |
+
<button class="icon-btn" id="nextBtn" title="Next (N)"><svg><use href="#i-next"/></svg></button>
|
| 82 |
+
<button class="icon-btn" id="repeatBtn" title="Repeat (R)"><svg><use href="#i-repeat"/></svg></button>
|
| 83 |
+
</div>
|
| 84 |
+
<div class="seekrow">
|
| 85 |
+
<span class="t" id="tCur">0:00</span>
|
| 86 |
+
<div class="seekwrap">
|
| 87 |
+
<input type="range" id="seek" min="0" max="1000" value="0" step="1" aria-label="Seek">
|
| 88 |
+
<div class="seekfill" id="seekFill"><span></span></div>
|
| 89 |
+
</div>
|
| 90 |
+
<span class="t" id="tDur">0:00</span>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<div class="right">
|
| 95 |
+
<div class="spinner" id="spinner" hidden><span></span></div>
|
| 96 |
+
<span class="badge" id="radioBadge" hidden><svg><use href="#i-spark"/></svg>Radio</span>
|
| 97 |
+
<button class="icon-btn" id="muteBtn" title="Mute (M)"><svg><use href="#i-vol"/></svg></button>
|
| 98 |
+
<input type="range" id="vol" class="vol" min="0" max="1" step="0.01" value="0.9" aria-label="Volume">
|
| 99 |
+
</div>
|
| 100 |
+
</footer>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<audio id="audio" preload="auto" crossorigin="anonymous"></audio>
|
| 104 |
+
<div class="toasts" id="toasts"></div>
|
| 105 |
+
|
| 106 |
+
<script src="/static/app.js"></script>
|
| 107 |
+
</body>
|
| 108 |
+
</html>
|
tunnel.sh
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Cadence — put your local backend on a free public https URL.
|
| 3 |
+
# No account, no credit card, no domain. All tooling is open source (Apache-2.0 / MIT).
|
| 4 |
+
#
|
| 5 |
+
# ./tunnel.sh normal run: checks everything, prints your https URL
|
| 6 |
+
# bash tunnel.sh same thing (works even if the file isn't executable)
|
| 7 |
+
# ./tunnel.sh --check diagnose only, print status, exit
|
| 8 |
+
# ./tunnel.sh --install download cloudflared into ./bin (no sudo)
|
| 9 |
+
# ./tunnel.sh --http2 force HTTP/2 (use when QUIC/UDP 7844 is blocked)
|
| 10 |
+
# ./tunnel.sh --ssh fallback tunnel via ssh (localhost.run) — no cloudflared
|
| 11 |
+
# ./tunnel.sh --with-backend also start the backend if it isn't running (installs deps if needed)
|
| 12 |
+
# PORT=8010 ./tunnel.sh different backend port
|
| 13 |
+
set -uo pipefail
|
| 14 |
+
cd "$(dirname "$0")"
|
| 15 |
+
|
| 16 |
+
PORT="${PORT:-8000}"
|
| 17 |
+
BASE="http://127.0.0.1:${PORT}"
|
| 18 |
+
BIN_DIR="./bin"
|
| 19 |
+
MODE="run"
|
| 20 |
+
PROTOCOL=""
|
| 21 |
+
WITH_BACKEND=0
|
| 22 |
+
|
| 23 |
+
for arg in "$@"; do
|
| 24 |
+
case "$arg" in
|
| 25 |
+
--check) MODE="check" ;;
|
| 26 |
+
--install) MODE="install" ;;
|
| 27 |
+
--http2) PROTOCOL="--protocol http2" ;;
|
| 28 |
+
--ssh) MODE="ssh" ;;
|
| 29 |
+
--with-backend|--serve) WITH_BACKEND=1 ;;
|
| 30 |
+
-h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
| 31 |
+
*) echo "unknown option: $arg (try --help)"; exit 2 ;;
|
| 32 |
+
esac
|
| 33 |
+
done
|
| 34 |
+
|
| 35 |
+
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
|
| 36 |
+
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
|
| 37 |
+
bad() { printf ' \033[31m✗\033[0m %s\n' "$*"; }
|
| 38 |
+
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
|
| 39 |
+
|
| 40 |
+
# ── find cloudflared: PATH, then ./bin ────────────────────────────────────────
|
| 41 |
+
find_cloudflared() {
|
| 42 |
+
if command -v cloudflared >/dev/null 2>&1; then command -v cloudflared; return 0; fi
|
| 43 |
+
if [ -x "${BIN_DIR}/cloudflared" ]; then echo "${BIN_DIR}/cloudflared"; return 0; fi
|
| 44 |
+
return 1
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
install_cloudflared() {
|
| 48 |
+
os="$(uname -s)"; arch="$(uname -m)"
|
| 49 |
+
case "$arch" in x86_64|amd64) a=amd64 ;; arm64|aarch64) a=arm64 ;; *) bad "unsupported CPU: $arch"; return 1 ;; esac
|
| 50 |
+
mkdir -p "$BIN_DIR"
|
| 51 |
+
if [ "$os" = "Darwin" ]; then
|
| 52 |
+
if command -v brew >/dev/null 2>&1; then brew install cloudflared && return 0; fi
|
| 53 |
+
url="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-${a}.tgz"
|
| 54 |
+
curl -fsSL "$url" | tar -xz -C "$BIN_DIR" cloudflared || return 1
|
| 55 |
+
elif [ "$os" = "Linux" ]; then
|
| 56 |
+
url="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${a}"
|
| 57 |
+
curl -fsSL -o "${BIN_DIR}/cloudflared" "$url" || return 1
|
| 58 |
+
else
|
| 59 |
+
bad "unsupported OS: $os — install cloudflared manually, or use --ssh"
|
| 60 |
+
return 1
|
| 61 |
+
fi
|
| 62 |
+
chmod +x "${BIN_DIR}/cloudflared"
|
| 63 |
+
return 0
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
backend_up() { curl -fsS -m 3 "${BASE}/healthz" >/dev/null 2>&1; }
|
| 67 |
+
|
| 68 |
+
py_bin() { if [ -x ./.venv/bin/python ]; then echo "./.venv/bin/python"; else echo "python3"; fi; }
|
| 69 |
+
deps_ok() { "$(py_bin)" -c 'import flask, yt_dlp, ytmusicapi, requests' >/dev/null 2>&1; }
|
| 70 |
+
|
| 71 |
+
install_deps() {
|
| 72 |
+
if deps_ok; then return 0; fi
|
| 73 |
+
bold "Installing python dependencies (first run only)"
|
| 74 |
+
if python3 -m pip install -q -r requirements.txt >/dev/null 2>&1 && \
|
| 75 |
+
python3 -c 'import flask, yt_dlp, ytmusicapi, requests' >/dev/null 2>&1; then
|
| 76 |
+
ok "installed into system python"; return 0
|
| 77 |
+
fi
|
| 78 |
+
warn "system pip refused (PEP 668) — using ./.venv"
|
| 79 |
+
[ -d .venv ] || python3 -m venv .venv || { bad "could not create .venv (install python3-venv)"; return 1; }
|
| 80 |
+
./.venv/bin/pip install -q -r requirements.txt || { bad "pip install failed — are you offline?"; return 1; }
|
| 81 |
+
ok "installed into ./.venv"; return 0
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
start_backend() {
|
| 85 |
+
bold "Starting the backend"
|
| 86 |
+
install_deps || return 1
|
| 87 |
+
: > .cadence-backend.log
|
| 88 |
+
PORT="$PORT" "$(py_bin)" app.py >>.cadence-backend.log 2>&1 &
|
| 89 |
+
BACKEND_PID=$!
|
| 90 |
+
printf ' waiting for %s' "$BASE"
|
| 91 |
+
for _ in $(seq 1 40); do
|
| 92 |
+
if backend_up; then printf '\n'; ok "backend up (pid ${BACKEND_PID})"; return 0; fi
|
| 93 |
+
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
|
| 94 |
+
printf '\n'; bad "the backend exited immediately — log:"; sed 's/^/ /' .cadence-backend.log | tail -12; return 1
|
| 95 |
+
fi
|
| 96 |
+
printf '.'; sleep 1
|
| 97 |
+
done
|
| 98 |
+
printf '\n'; bad "backend did not answer after 40 s — log:"; sed 's/^/ /' .cadence-backend.log | tail -12; return 1
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
# why is nothing on the port? Say so specifically instead of hand-waving.
|
| 102 |
+
diagnose_backend() {
|
| 103 |
+
if command -v ss >/dev/null 2>&1 && ss -ltn 2>/dev/null | grep -q ":${PORT} "; then
|
| 104 |
+
warn "something else is already listening on port ${PORT} — try: PORT=$((PORT+1)) bash tunnel.sh --with-backend"
|
| 105 |
+
return
|
| 106 |
+
fi
|
| 107 |
+
if command -v lsof >/dev/null 2>&1 && lsof -iTCP:"${PORT}" -sTCP:LISTEN >/dev/null 2>&1; then
|
| 108 |
+
warn "port ${PORT} is held by another program — try: PORT=$((PORT+1)) bash tunnel.sh --with-backend"
|
| 109 |
+
return
|
| 110 |
+
fi
|
| 111 |
+
if ! deps_ok; then
|
| 112 |
+
warn "python dependencies are not installed yet (./run.sh does that on first start)"
|
| 113 |
+
fi
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
# ── --install ─��───────────────────────────────────────────────────────────────
|
| 117 |
+
if [ "$MODE" = "install" ]; then
|
| 118 |
+
bold "Installing cloudflared (Apache-2.0) into ${BIN_DIR}/"
|
| 119 |
+
if install_cloudflared; then ok "installed: $($BIN_DIR/cloudflared --version 2>/dev/null | head -1)"; exit 0; fi
|
| 120 |
+
bad "download failed — check your connection, or use: ./tunnel.sh --ssh"
|
| 121 |
+
exit 1
|
| 122 |
+
fi
|
| 123 |
+
|
| 124 |
+
# ── --check ───────────────────────────────────────────────────────────────────
|
| 125 |
+
if [ "$MODE" = "check" ]; then
|
| 126 |
+
bold "Cadence tunnel diagnostics"
|
| 127 |
+
python3 -c 'import flask, yt_dlp, ytmusicapi, requests' 2>/dev/null \
|
| 128 |
+
&& ok "python dependencies present" \
|
| 129 |
+
|| bad "python dependencies missing — run ./run.sh once"
|
| 130 |
+
if backend_up; then ok "backend answering on ${BASE}"
|
| 131 |
+
else bad "backend NOT answering on ${BASE} — start it with bash run.sh, or run: bash tunnel.sh --with-backend"; diagnose_backend; fi
|
| 132 |
+
if cf="$(find_cloudflared)"; then ok "cloudflared found: $cf ($("$cf" --version 2>/dev/null | head -1))"
|
| 133 |
+
else warn "cloudflared not installed — ./tunnel.sh will download it, or use --ssh"; fi
|
| 134 |
+
if curl -fsS -m 6 https://api.cloudflare.com/client/v4/ips >/dev/null 2>&1; then ok "cloudflare reachable (https 443)"
|
| 135 |
+
else bad "can't reach Cloudflare — a firewall/proxy may be blocking the tunnel"; fi
|
| 136 |
+
if command -v ssh >/dev/null 2>&1; then ok "ssh available (fallback tunnel)"; else warn "no ssh (fallback unavailable)"; fi
|
| 137 |
+
exit 0
|
| 138 |
+
fi
|
| 139 |
+
|
| 140 |
+
# ── --ssh fallback ────────────────────────────────────────────────────────────
|
| 141 |
+
if [ "$MODE" = "ssh" ]; then
|
| 142 |
+
backend_up || { bad "Backend isn't running on ${BASE} — start ./run.sh first"; exit 1; }
|
| 143 |
+
command -v ssh >/dev/null 2>&1 || { bad "ssh is not installed"; exit 1; }
|
| 144 |
+
bold "Opening an ssh tunnel via localhost.run (no account)"
|
| 145 |
+
echo " The URL appears below as https://….lhr.life — press Ctrl+C to stop."
|
| 146 |
+
echo
|
| 147 |
+
exec ssh -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30 \
|
| 148 |
+
-R 80:localhost:"${PORT}" nokey@localhost.run
|
| 149 |
+
fi
|
| 150 |
+
|
| 151 |
+
# ── normal run ────────────────────────────────────────────────────────────────
|
| 152 |
+
bold "1/3 Checking the backend"
|
| 153 |
+
if backend_up; then
|
| 154 |
+
ok "answering on ${BASE}"
|
| 155 |
+
elif [ "$WITH_BACKEND" = "1" ]; then
|
| 156 |
+
start_backend || { echo; bad "could not start the backend"; exit 1; }
|
| 157 |
+
elif [ -t 0 ]; then
|
| 158 |
+
# interactive: just offer to do it, instead of making the user open a 2nd terminal
|
| 159 |
+
bad "nothing is running on ${BASE}"
|
| 160 |
+
diagnose_backend
|
| 161 |
+
printf '\n Start it now? [Y/n] '
|
| 162 |
+
read -r answer </dev/tty || answer="n"
|
| 163 |
+
case "$answer" in
|
| 164 |
+
""|y|Y|yes|YES)
|
| 165 |
+
start_backend || { echo; bad "could not start the backend"; exit 1; } ;;
|
| 166 |
+
*)
|
| 167 |
+
cat <<EOF
|
| 168 |
+
|
| 169 |
+
Start it first, in another terminal:
|
| 170 |
+
|
| 171 |
+
bash run.sh # macOS / Linux (creates .venv and installs deps)
|
| 172 |
+
python app.py # if dependencies are already installed
|
| 173 |
+
|
| 174 |
+
…or let this script handle it: bash tunnel.sh --with-backend
|
| 175 |
+
Different port? PORT=8010 bash tunnel.sh --with-backend
|
| 176 |
+
EOF
|
| 177 |
+
exit 1 ;;
|
| 178 |
+
esac
|
| 179 |
+
else
|
| 180 |
+
# non-interactive (piped/CI): never block on a prompt
|
| 181 |
+
bad "nothing is running on ${BASE}"
|
| 182 |
+
diagnose_backend
|
| 183 |
+
cat <<EOF
|
| 184 |
+
|
| 185 |
+
Start it first, in another terminal:
|
| 186 |
+
|
| 187 |
+
bash run.sh # macOS / Linux (creates .venv and installs deps)
|
| 188 |
+
python app.py # if dependencies are already installed
|
| 189 |
+
|
| 190 |
+
…or let this script handle it: bash tunnel.sh --with-backend
|
| 191 |
+
Different port? PORT=8010 bash tunnel.sh --with-backend
|
| 192 |
+
EOF
|
| 193 |
+
exit 1
|
| 194 |
+
fi
|
| 195 |
+
|
| 196 |
+
bold "2/3 Checking cloudflared"
|
| 197 |
+
if cf="$(find_cloudflared)"; then
|
| 198 |
+
ok "found: $cf"
|
| 199 |
+
else
|
| 200 |
+
warn "not installed — downloading it now (Apache-2.0, no sudo, goes in ./bin)"
|
| 201 |
+
if install_cloudflared; then
|
| 202 |
+
cf="${BIN_DIR}/cloudflared"; ok "installed"
|
| 203 |
+
else
|
| 204 |
+
bad "download failed. Either:"
|
| 205 |
+
echo " brew install cloudflared # macOS"
|
| 206 |
+
echo " ./tunnel.sh --ssh # fallback, no cloudflared needed"
|
| 207 |
+
exit 1
|
| 208 |
+
fi
|
| 209 |
+
fi
|
| 210 |
+
|
| 211 |
+
bold "3/3 Opening the tunnel"
|
| 212 |
+
echo " (first run takes 5–20 s; press Ctrl+C to stop)"
|
| 213 |
+
echo
|
| 214 |
+
|
| 215 |
+
TUNNEL_PID=""
|
| 216 |
+
run_tunnel() { # $1 = logfile — sets TUNNEL_PID (no subshell, so signals reach it)
|
| 217 |
+
# shellcheck disable=SC2086
|
| 218 |
+
"$cf" tunnel --url "${BASE}" --no-autoupdate $PROTOCOL >"$1" 2>&1 &
|
| 219 |
+
TUNNEL_PID=$!
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
cleanup() {
|
| 223 |
+
[ -n "${TUNNEL_PID:-}" ] && kill "$TUNNEL_PID" 2>/dev/null
|
| 224 |
+
if [ -n "${BACKEND_PID:-}" ]; then kill "$BACKEND_PID" 2>/dev/null; echo " (backend stopped — it was started by this script)"; fi
|
| 225 |
+
}
|
| 226 |
+
trap cleanup EXIT INT TERM
|
| 227 |
+
|
| 228 |
+
LOG="${TMPDIR:-/tmp}/cadence-tunnel.$$.log"
|
| 229 |
+
run_tunnel "$LOG"
|
| 230 |
+
PID="$TUNNEL_PID"
|
| 231 |
+
|
| 232 |
+
URL=""
|
| 233 |
+
for _ in $(seq 1 60); do
|
| 234 |
+
sleep 1
|
| 235 |
+
URL="$(grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG" 2>/dev/null | head -1)"
|
| 236 |
+
[ -n "$URL" ] && break
|
| 237 |
+
if ! kill -0 "$PID" 2>/dev/null; then break; fi
|
| 238 |
+
done
|
| 239 |
+
|
| 240 |
+
if [ -z "$URL" ]; then
|
| 241 |
+
# QUIC (UDP 7844) is blocked on many networks — retry over HTTP/2 before giving up
|
| 242 |
+
warn "no URL yet — retrying over HTTP/2 (QUIC is often blocked by firewalls/routers)"
|
| 243 |
+
kill "$PID" 2>/dev/null; wait "$PID" 2>/dev/null
|
| 244 |
+
PROTOCOL="--protocol http2"
|
| 245 |
+
run_tunnel "$LOG"
|
| 246 |
+
PID="$TUNNEL_PID"
|
| 247 |
+
for _ in $(seq 1 60); do
|
| 248 |
+
sleep 1
|
| 249 |
+
URL="$(grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG" 2>/dev/null | head -1)"
|
| 250 |
+
[ -n "$URL" ] && break
|
| 251 |
+
kill -0 "$PID" 2>/dev/null || break
|
| 252 |
+
done
|
| 253 |
+
fi
|
| 254 |
+
|
| 255 |
+
if [ -z "$URL" ]; then
|
| 256 |
+
bad "couldn't get a tunnel URL. Here's what cloudflared said:"
|
| 257 |
+
echo "────────────────────────────────────────────────────────────"
|
| 258 |
+
tail -20 "$LOG" 2>/dev/null | sed 's/^/ /'
|
| 259 |
+
echo "────────────────────────────────────────────────────────────"
|
| 260 |
+
cat <<EOF
|
| 261 |
+
Common causes:
|
| 262 |
+
• corporate / school network blocking outbound 7844/udp and 443 → ./tunnel.sh --ssh
|
| 263 |
+
• VPN or firewall intercepting TLS → turn it off, retry
|
| 264 |
+
• cloudflared too old → rm -rf bin && ./tunnel.sh
|
| 265 |
+
Alternative that needs no cloudflared: ./tunnel.sh --ssh
|
| 266 |
+
EOF
|
| 267 |
+
kill "$PID" 2>/dev/null
|
| 268 |
+
exit 1
|
| 269 |
+
fi
|
| 270 |
+
|
| 271 |
+
printf '\n'
|
| 272 |
+
bold " ┌──────────────────────────────────────────────────────────────┐"
|
| 273 |
+
printf ' │ \033[36mCadence is live at:\033[0m\n'
|
| 274 |
+
printf ' │ \033[1;4m%s\033[0m\n' "$URL"
|
| 275 |
+
bold " └──────────────────────────────────────────────────────────────┘"
|
| 276 |
+
echo
|
| 277 |
+
echo " Open that URL on your phone → Install Cadence."
|
| 278 |
+
echo " Audio streams from this machine, so playback avoids the datacenter block."
|
| 279 |
+
echo
|
| 280 |
+
|
| 281 |
+
# prove it end-to-end before handing it over (DNS for a new subdomain takes a moment)
|
| 282 |
+
verified=""
|
| 283 |
+
for i in $(seq 1 10); do
|
| 284 |
+
# IPv6-only DNS answers are common on fresh subdomains; try both stacks
|
| 285 |
+
if curl -fsS -m 6 --ipv4 -o /dev/null "${URL}/healthz" 2>/dev/null \
|
| 286 |
+
|| curl -fsS -m 6 -o /dev/null "${URL}/healthz" 2>/dev/null; then verified="yes"; break; fi
|
| 287 |
+
if [ "$i" = 1 ]; then printf ' waiting for the tunnel to settle…'; fi
|
| 288 |
+
printf '.'
|
| 289 |
+
sleep 2
|
| 290 |
+
done
|
| 291 |
+
[ -n "$verified" ] && printf '\n'
|
| 292 |
+
if [ -n "$verified" ]; then
|
| 293 |
+
ok "verified from the public internet (healthz answered)"
|
| 294 |
+
else
|
| 295 |
+
printf '\n'
|
| 296 |
+
echo " (the tunnel is running; the first request can take ~10 s — just open the URL)"
|
| 297 |
+
fi
|
| 298 |
+
|
| 299 |
+
echo
|
| 300 |
+
if [ -n "${BACKEND_PID:-}" ]; then
|
| 301 |
+
echo " Keep this terminal open. Ctrl+C closes the tunnel and the backend."
|
| 302 |
+
else
|
| 303 |
+
echo " Keep this terminal open. Ctrl+C closes the tunnel."
|
| 304 |
+
fi
|
| 305 |
+
echo
|
| 306 |
+
|
| 307 |
+
wait "$PID"
|