Vincisteel commited on
Commit
568e7b7
·
verified ·
1 Parent(s): 6ba2acb

Upload 45 files

Browse files
.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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, '&quot;')}">
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 &amp; 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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')}&nbsp;Surprise me</button>
124
+ <button class="chip" data-action="focus-search">${icon('search')}&nbsp;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')}&nbsp;Play artist radio</button>
192
+ ${a.top_tracks.length ? `<button class="chip" data-action="play-list" data-list="top">${icon('play')}&nbsp;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 &amp; 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')}&nbsp;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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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')}&nbsp;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')}&nbsp;<span id="heroBtnLabel">Play</span></button>
281
+ <button class="chip" data-action="similar-now">${icon('spark')}&nbsp;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')}&nbsp;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')}&nbsp;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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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')}&nbsp;Play artist radio</button>
585
+ ${a.top_tracks.length ? `<button class="chip" data-action="play-list" data-list="top">${icon('play')}&nbsp;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 &amp; 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 &amp; 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"