PageSourceSearch

https://www.cliamp.stream/

html cliamp.stream collected 2026-09-30 05:17:14 UTC 77,771 bytes, 1,270 lines download raw bytes

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.0">
6<title>cliamp — terminal music player</title>
7<meta name="description" content="A full music player that lives in your terminal. Play local files, podcasts, Spotify, Qobuz, Tidal, YouTube Music, Plex, Jellyfin, Emby, Navidrome, Lyrion, SoundCloud, Mixcloud, NetEase, Yandex Music, and about 58,000 radio stations, with a 10-band equalizer, visualizers, synced lyrics, and Lua plugins.">
8<link rel="icon" type="image/svg+xml" href="favicon.svg" id="favicon">
9<link rel="apple-touch-icon" href="apple-touch-icon.png">
10<meta name="theme-color" content="#0b0c0d">
11
12<!-- Open Graph -->
13<meta property="og:type" content="website">
14<meta property="og:title" content="cliamp — terminal music player">
15<meta property="og:description" content="A full music player that lives in your terminal. Streaming services, media servers, podcasts, and 58,000 radio stations with a 10-band EQ, visualizers, synced lyrics, and Lua plugins.">
16<meta property="og:image" content="https://cliamp.stream/og-image.png?v=2">
17<meta property="og:image:width" content="1200">
18<meta property="og:image:height" content="630">
19<meta property="og:url" content="https://cliamp.stream">
20
21<!-- Twitter Card -->
22<meta name="twitter:card" content="summary_large_image">
23<meta name="twitter:title" content="cliamp — terminal music player">
24<meta name="twitter:description" content="A full music player that lives in your terminal. Streaming services, media servers, podcasts, and 58,000 radio stations with a 10-band EQ, visualizers, synced lyrics, and Lua plugins.">
25<meta name="twitter:image" content="https://cliamp.stream/og-image.png?v=2">
26
27<link rel="preconnect" href="https://fonts.googleapis.com">
28<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
29<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
30
31<style>
32/* ── tokens ──────────────────────────────────────────────────────── */
33:root{
34  color-scheme:dark;
35  --bg:#0b0c0d;--panel:#0e1012;--panel-2:#121517;--panel-3:#15181a;
36  --line:#23282b;--line-2:#1c2124;--line-3:#16191b;--line-hi:#2b3134;
37  --fg:#e9ece9;--fg-2:#98a09f;--mute:#6a7273;--mute-2:#8a9291;
38  --accent:var(--st-accent,#74e08c);--accent-hi:var(--st-accent-hi,#a9f0b8);
39  --warm:var(--st-warm,#e9b455);--purple:#c39ad9;--rose:#d99a8f;--blue:#8fb3d9;--eq-neg:#3a4145;
40  --viz-bg:#0a0c0d;--top-bg:rgba(11,12,13,.85);
41  --sans:'Space Grotesk',system-ui,-apple-system,"Segoe UI",sans-serif;
42  --mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
43  --wrap:1240px;--pad:clamp(16px,3vw,32px);
44}
45/* Omarchy Theme Sync (omacom/omarchy-theme-sync) publishes the desktop theme as
46   --omarchy-* properties plus data-omarchy-theme/-mode on <html>. When it is
47   present the page follows the desktop: seeds come from colors.toml, every
48   in-between tone is mixed from them, and the per-channel retint yields to it. */
49html[data-omarchy-theme]{
50  --bg:var(--omarchy-background,#0b0c0d);
51  --fg:var(--omarchy-bright-foreground,var(--omarchy-foreground,#e9ece9));
52  --accent:var(--omarchy-accent,var(--st-accent,#74e08c));
53  --accent-hi:color-mix(in srgb,var(--accent) 66%,var(--fg));
54  --warm:var(--omarchy-yellow,var(--omarchy-bright-yellow,var(--st-warm,#e9b455)));
55  --purple:var(--omarchy-magenta,var(--omarchy-bright-magenta,#c39ad9));
56  --rose:var(--omarchy-red,var(--omarchy-bright-red,#d99a8f));
57  --blue:var(--omarchy-blue,var(--omarchy-bright-blue,#8fb3d9));
58  --fg-2:var(--omarchy-dark-foreground,color-mix(in srgb,var(--fg) 62%,var(--bg)));
59  --mute:var(--omarchy-muted,color-mix(in srgb,var(--fg) 42%,var(--bg)));
60  --mute-2:color-mix(in srgb,var(--fg) 52%,var(--bg));
61  --panel:color-mix(in srgb,var(--bg) 97%,var(--fg));
62  --panel-2:color-mix(in srgb,var(--bg) 94%,var(--fg));
63  --panel-3:color-mix(in srgb,var(--bg) 92%,var(--fg));
64  --line-3:color-mix(in srgb,var(--bg) 93%,var(--fg));
65  --line-2:color-mix(in srgb,var(--bg) 90%,var(--fg));
66  --line:color-mix(in srgb,var(--bg) 86%,var(--fg));
67  --line-hi:color-mix(in srgb,var(--bg) 80%,var(--fg));
68  --eq-neg:color-mix(in srgb,var(--bg) 78%,var(--fg));
69  --viz-bg:var(--bg);
70  --top-bg:color-mix(in srgb,var(--bg) 85%,transparent);
71}
72html[data-omarchy-mode="light"]{color-scheme:light}
73*{box-sizing:border-box}
74html{-webkit-text-size-adjust:100%;scroll-behavior:smooth}
75@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
76body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);font-size:16px;line-height:1.55;-webkit-font-smoothing:antialiased;overflow-x:hidden}
77a{color:var(--accent);text-decoration:none}
78a:hover{color:var(--accent-hi);text-decoration:underline}
79::selection{background:var(--accent);color:var(--bg)}
80input[type=range]{accent-color:var(--accent)}
81:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
82button{font:inherit}
83h1,h2,h3{font-family:var(--sans);font-weight:500;letter-spacing:-.03em;margin:0}
84h2{font-size:clamp(26px,3.4vw,38px);line-height:1.1}
85.sub{color:var(--fg-2);margin:10px 0 0;font-size:16px;max-width:58ch}
86.sub code,.keys p code{font-family:var(--mono);color:var(--fg);font-size:.92em}
87#bg{position:fixed;inset:0;width:100%;height:100%;display:block;z-index:0;pointer-events:none;opacity:.45;transition:opacity .9s ease}
88.shell{position:relative;z-index:1;min-height:100vh}
89.wrap{max-width:var(--wrap);margin:0 auto;padding:0 var(--pad)}
90noscript p{font-family:var(--mono);font-size:12px;color:var(--warm);margin:0;padding:8px var(--pad);text-align:center}
91
92/* ── header ──────────────────────────────────────────────────────── */
93.top{position:sticky;top:0;z-index:40;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);background:var(--top-bg);border-bottom:1px solid var(--line)}
94.top-in{max-width:var(--wrap);margin:0 auto;padding:10px var(--pad);display:flex;align-items:center;gap:18px;flex-wrap:wrap}
95.brand{display:flex;align-items:center;gap:9px;font-family:var(--mono);font-size:15px;letter-spacing:.14em;color:var(--fg)}
96.brand:hover{text-decoration:none;color:var(--fg)}
97.brand .logo{width:22px;height:22px;display:block}
98.brand b{color:var(--accent);font-weight:600}
99.nav{display:flex;align-items:center;gap:4px;flex-wrap:wrap;font-family:var(--mono);font-size:12.5px;margin-left:auto}
100.nav a{color:var(--fg-2);padding:6px 10px;border-radius:4px}
101.nav a:hover{color:var(--fg);background:var(--panel-3);text-decoration:none}
102.nav a.cta{color:var(--bg);background:var(--accent);padding:6px 12px;font-weight:500}
103.nav a.cta:hover{background:var(--accent-hi);color:var(--bg)}
104@media (max-width:760px){.nav a.opt{display:none}}
105
106/* ── sections ────────────────────────────────────────────────────── */
107section{padding:clamp(40px,5vw,72px) 0;border-top:1px solid var(--line-3)}
108.sec-head{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;flex-wrap:wrap}
109.grid{display:grid;gap:12px;margin-top:26px}
110.link-btn{font-family:var(--mono);font-size:12.5px;border:1px solid var(--line-hi);border-radius:4px;padding:8px 13px;color:var(--fg);white-space:nowrap}
111.link-btn:hover{border-color:var(--accent);color:var(--accent);text-decoration:none}
112
113/* ── hero ────────────────────────────────────────────────────────── */
114.hero{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:clamp(28px,4vw,56px);align-items:center;padding:clamp(40px,6vw,80px) 0 clamp(28px,4vw,48px);border-top:0}
115.eyebrow{font-family:var(--mono);font-size:12px;letter-spacing:.22em;color:var(--mute);text-transform:uppercase}
116.hero h1{font-size:clamp(48px,8vw,92px);line-height:.94;margin:18px 0 0}
117.tagline{font-family:var(--mono);font-size:clamp(16px,2vw,21px);color:var(--fg);margin:16px 0 0;line-height:1.4}
118.tagline em{color:var(--warm)}
119.lede{font-size:16.5px;line-height:1.62;color:var(--fg-2);margin:18px 0 0;max-width:56ch;text-wrap:pretty}
120.lede b{color:var(--fg);font-weight:500}
121.btns{display:flex;gap:10px;flex-wrap:wrap;margin-top:28px;font-family:var(--mono);font-size:14px}
122.btn{display:inline-block;padding:12px 20px;border-radius:5px;border:1px solid var(--line-hi);color:var(--fg);cursor:pointer;background:transparent;font-family:var(--mono);font-size:14px;line-height:1.2}
123.btn:hover{border-color:var(--accent);color:var(--accent);text-decoration:none}
124.btn.go{background:var(--accent);color:var(--bg);border-color:var(--accent);font-weight:600}
125.btn.go:hover{background:var(--accent-hi);border-color:var(--accent-hi);color:var(--bg)}
126.chips{display:flex;gap:8px;flex-wrap:wrap;margin-top:24px;font-family:var(--mono);font-size:11.5px;color:var(--mute)}
127.chips span{border:1px solid var(--line);border-radius:3px;padding:5px 9px}
128
129/* ── terminal player ─────────────────────────────────────────────── */
130.tty-cap{font-family:var(--mono);font-size:11.5px;color:var(--mute);display:flex;justify-content:space-between;padding:0 4px 8px;gap:10px}
131.tty-cap b{color:var(--accent);font-weight:400}
132.tty{border:1px solid var(--line);border-radius:8px;background:var(--panel);box-shadow:0 30px 70px -30px rgba(0,0,0,.9);overflow:hidden}
133.tty-bar{display:flex;align-items:center;gap:10px;padding:9px 12px;border-bottom:1px solid var(--line-2);background:var(--panel-2);font-family:var(--mono);font-size:11px;color:var(--mute)}
134.dots{display:flex;gap:5px}
135.dots span{width:9px;height:9px;border-radius:50%;background:var(--line-hi)}
136.tty-bar .brandline{margin-left:auto;letter-spacing:.24em;color:var(--accent)}
137.tty-body{padding:16px;font-family:var(--mono)}
138.t-title{display:flex;align-items:baseline;gap:10px}
139.t-title .note{color:var(--warm)}
140#t-name{font-size:clamp(15px,1.7vw,19px);color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
141#t-meta{font-size:12px;color:var(--fg-2);margin-top:6px;min-height:15px}
142.t-status{display:flex;align-items:center;gap:12px;margin-top:14px;font-size:11.5px;color:var(--fg-2)}
143#t-elapsed{color:var(--accent)}
144#t-state{border:1px solid var(--line);border-radius:3px;padding:3px 7px;color:var(--mute)}
145#t-live{margin-left:auto;color:var(--mute)}
146#t-live.live{color:var(--accent)}
147#viz{width:100%;height:92px;display:block;margin-top:14px;border:1px solid var(--line-2);border-radius:4px;background:var(--viz-bg)}
148.t-eqvol{display:flex;align-items:center;gap:10px;margin-top:12px;font-size:11px;color:var(--mute);flex-wrap:wrap}
149.t-eqvol .lab{color:var(--warm)}
150#eq{display:flex;align-items:flex-end;gap:3px;height:22px}
151#eq span{width:4px;border-radius:1px;display:block;background:var(--accent)}
152#eq span.neg{background:var(--eq-neg)}
153.vol{margin-left:auto;display:flex;align-items:center;gap:8px}
154.vol input{width:88px;height:3px}
155#voldb{color:var(--fg-2);min-width:30px}
156.t-ctl{display:flex;align-items:center;gap:8px;margin-top:14px}
157.tbtn{font-family:var(--mono);background:var(--panel-3);color:var(--fg-2);border:1px solid var(--line);border-radius:4px;padding:7px 11px;cursor:pointer;font-size:12px}
158.tbtn:hover{color:var(--fg);border-color:var(--line-hi)}
159.tbtn.play{background:var(--accent);color:var(--bg);border-color:var(--accent);padding:8px 18px;font-size:13px;font-weight:600;min-width:96px}
160.tbtn.play:hover{background:var(--accent-hi);border-color:var(--accent-hi);color:var(--bg)}
161.t-src{margin-left:auto;font-size:11px;color:var(--mute)}
162.t-src b{color:var(--fg-2);font-weight:400}
163.t-plhead{margin-top:14px;border-top:1px solid var(--line-2);padding-top:10px;font-size:11px;color:var(--mute);display:flex;gap:10px;flex-wrap:wrap}
164#t-list{max-height:190px;overflow-y:auto;margin-top:6px;display:flex;flex-direction:column;gap:1px;scrollbar-width:thin;scrollbar-color:var(--line-hi) transparent}
165.st{all:unset;cursor:pointer;display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:3px;font-family:var(--mono);font-size:12.5px;color:var(--fg-2);box-sizing:border-box;width:100%}
166.st:hover{background:var(--panel-3)}
167.st:focus-visible{outline:2px solid var(--accent)}
168.st.on{background:var(--panel-3);color:var(--fg)}
169.st .mark{color:var(--mute-2);width:10px}
170.st.on .mark{color:var(--accent)}
171.st .num{color:var(--mute-2);min-width:18px}
172.st .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
173.st .tag{color:var(--fg-2);font-size:11.5px;margin-left:6px;white-space:nowrap}
174.st .cnt{margin-left:auto;color:var(--accent);font-size:11px;white-space:nowrap}
175.st .fav{color:var(--mute-2);padding:0 2px;font-size:13px}
176.st .fav.on{color:var(--warm)}
177.tty-hint{margin-top:10px;font-family:var(--mono);font-size:11.5px;color:var(--mute);display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;padding:0 4px}
178.tty-hint code{color:var(--fg-2);font-family:inherit;word-break:break-all}
179.tty-hint code .p{color:var(--accent)}
180
181/* ── install ─────────────────────────────────────────────────────── */
182.g-install{grid-template-columns:repeat(auto-fit,minmax(228px,1fr))}
183.copy-card{all:unset;cursor:pointer;display:block;border:1px solid var(--line);border-radius:6px;padding:16px;background:var(--panel);box-sizing:border-box}
184.copy-card:hover{border-color:var(--accent)}
185.copy-card:focus-visible{outline:2px solid var(--accent)}
186.cc-head{display:flex;justify-content:space-between;font-family:var(--mono);font-size:11px;color:var(--mute);letter-spacing:.14em}
187.cc-head [data-copylabel]{color:var(--accent)}
188.cc-cmd{display:block;font-family:var(--mono);font-size:13px;color:var(--fg);margin-top:10px;word-break:break-all}
189.note{font-family:var(--mono);font-size:12.5px;color:var(--mute);margin:16px 0 0;line-height:1.8}
190.note b{color:var(--accent);font-weight:400}
191.note i{color:var(--fg-2);font-style:normal}
192
193/* ── sources ─────────────────────────────────────────────────────── */
194.filters{display:flex;gap:6px;flex-wrap:wrap;font-family:var(--mono);font-size:12px}
195.filters button{all:unset;cursor:pointer;padding:6px 11px;border-radius:4px;border:1px solid var(--line);font-family:var(--mono);font-size:12px;color:var(--fg-2)}
196.filters button:hover{color:var(--fg);border-color:var(--line-hi)}
197.filters button:focus-visible{outline:2px solid var(--accent)}
198.filters button.on{background:var(--accent);color:var(--bg);border-color:var(--accent)}
199.g-sources{grid-template-columns:repeat(auto-fill,minmax(215px,1fr));gap:10px;margin-top:24px}
200.src{border:1px solid var(--line);border-radius:6px;padding:15px 16px;background:var(--panel);transition:border-color .15s}
201.src:hover{border-color:var(--c)}
202.src-name{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:14px;color:var(--fg)}
203.dot{width:7px;height:7px;border-radius:50%;background:var(--c);flex:0 0 auto}
204.src-note{font-family:var(--mono);font-size:11.5px;color:var(--mute);margin-top:8px}
205
206/* ── features ────────────────────────────────────────────────────── */
207.g-feat{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
208.feat{border:1px solid var(--line);border-radius:6px;padding:20px;background:var(--panel)}
209.feat:hover{border-color:var(--line-hi)}
210.feat-k{font-family:var(--mono);color:var(--accent);font-size:12px;letter-spacing:.14em}
211.feat-k.alt{color:var(--purple)}
212.feat p{color:var(--fg-2);margin:10px 0 0;font-size:15.5px;line-height:1.55}
213.feat p b{color:var(--fg);font-weight:500}
214kbd{font-family:var(--mono);font-size:12px;color:var(--fg);border:1px solid var(--line);border-radius:3px;padding:1px 5px;white-space:nowrap}
215
216/* ── radio channels ──────────────────────────────────────────────── */
217.g-ch{grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px;margin-top:24px}
218.ch{border:1px solid var(--line);border-radius:6px;background:var(--panel);padding:15px 16px;display:flex;flex-direction:column;gap:12px;transition:border-color .15s}
219.ch:hover,.ch.on{border-color:var(--c)}
220.ch-head{display:flex;align-items:center;gap:9px}
221.ch-name{font-family:var(--mono);font-size:15px;color:var(--fg)}
222.ch-tag{font-family:var(--mono);font-size:11.5px;color:var(--fg-2);margin-left:auto;white-space:nowrap}
223.ch-url{font-family:var(--mono);font-size:11.5px;color:var(--fg-2);word-break:break-all}
224.ch-foot{display:flex;gap:6px;font-family:var(--mono);font-size:12px;align-items:center;flex-wrap:wrap}
225.ch-foot .cnt{margin-left:auto;color:var(--mute);font-size:11.5px;white-space:nowrap}
226.ch-foot .cnt.live{color:var(--c)}
227.mini{all:unset;cursor:pointer;border:1px solid var(--line);border-radius:4px;padding:6px 12px;color:var(--fg-2);font-family:var(--mono);font-size:12px}
228.mini:hover{color:var(--fg);border-color:var(--line-hi)}
229.mini:focus-visible{outline:2px solid var(--accent)}
230.mini.pri{background:var(--panel-3);border-color:var(--line-hi);color:var(--fg)}
231.mini.pri:hover{border-color:var(--c);color:var(--c)}
232
233/* ── stats ───────────────────────────────────────────────────────── */
234.big{display:flex;gap:22px;font-family:var(--mono)}
235.big .n{font-size:clamp(24px,3vw,34px);color:var(--accent);letter-spacing:-.02em;line-height:1.1}
236.big .n.fg{color:var(--fg)}
237.big .l{font-size:11.5px;color:var(--fg-2);letter-spacing:.12em}
238.g-stats{grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:clamp(16px,3vw,32px);margin-top:26px;align-items:stretch}
239.globe-card{border:1px solid var(--line);border-radius:8px;background:var(--panel);overflow:hidden;min-height:380px;
239display:flex;flex-direction:column}
240.card-bar{display:flex;justify-content:space-between;gap:10px;padding:9px 14px;border-bottom:1px solid var(--line-2);font-family:var(--mono);font-size:11px;color:var(--fg-2)}
241.card-bar b{color:var(--accent);font-weight:400;white-space:nowrap}
242.card-bar .mute{color:var(--mute)}
243.globe-wrap{position:relative;flex:1;min-height:340px}
244#globe{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:grab;touch-action:none}
245#globe.drag{cursor:grabbing}
246#tip{position:fixed;pointer-events:none;opacity:0;left:0;top:0;font:11.5px var(--mono);background:var(--panel-2);border:1px solid var(--line-hi);color:var(--fg);padding:5px 8px;border-radius:4px;white-space:nowrap;transform:translate(-50%,-150%);transition:opacity .12s;z-index:50}
247.globe-empty{position:absolute;inset:0;display:none;align-items:center;justify-content:center;font-family:var(--mono);font-size:12px;color:var(--mute);text-align:center;padding:20px}
248.globe-empty.show{display:flex}
249.side{display:flex;flex-direction:column;gap:14px}
250.side-h{font-family:var(--mono);font-size:11.5px;color:var(--fg-2);letter-spacing:.12em;display:flex;justify-content:space-between;gap:10px}
251#countries{display:flex;flex-direction:column;gap:9px;min-height:60px}
252.row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;font-family:var(--mono);font-size:12.5px}
253.row .nm{color:var(--fg);display:flex;justify-content:space-between}
254.row .bar{height:4px;background:var(--line-2);border-radius:2px;margin-top:5px;overflow:hidden}
255.row .bar i{display:block;height:100%;background:var(--accent)}
256.row .v{color:var(--fg-2);min-width:48px;text-align:right}
257.err{font-family:var(--mono);font-size:12px;color:var(--rose);margin:12px 0 0;min-height:0}
258
259/* ── keys ────────────────────────────────────────────────────────── */
260.keys{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:clamp(24px,4vw,48px)}
261.keys p{line-height:1.6;max-width:46ch}
262#keys{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;align-content:start}
263.key{border:1px solid var(--line);border-radius:5px;padding:11px 13px;background:var(--panel)}
264.key .k{font-family:var(--mono);font-size:13px;color:var(--accent)}
265.key .d{font-size:13px;color:var(--fg-2);margin-top:4px}
266
267/* ── plugins ─────────────────────────────────────────────────────── */
268.g-plug{grid-template-columns:repeat(auto-fit,minmax(300px,1fr));margin-top:24px;align-items:start}
269.code{border:1px solid var(--line);border-radius:6px;background:var(--panel);overflow:hidden}
270.code pre{margin:0;padding:14px;font-family:var(--mono);font-size:12.5px;line-height:1.7;color:var(--fg-2);overflow-x:auto}
271.code .c{color:var(--mute)}.code .k{color:var(--accent)}.code .s{color:var(--warm)}
272.card-bar button{all:unset;cursor:pointer;color:var(--accent);font-size:11px;font-family:var(--mono)}
273.card-bar button:hover{color:var(--accent-hi)}
274.api{display:flex;flex-direction:column;gap:8px}
275.api-i{border:1px solid var(--line);border-radius:6px;padding:14px 16px;background:var(--panel)}
276.api-i .sig{font-family:var(--mono);font-size:12.5px;color:var(--fg);overflow-wrap:anywhere}
277.api-i .d{color:var(--mute);font-size:13.5px;margin-top:5px}
278.community{margin-top:28px}
279.g-comm{grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:8px;margin-top:12px}
280.comm{border:1px solid var(--line);border-radius:6px;padding:12px 14px;background:var(--panel);font-size:13.5px;color:var(--mute)}
281.comm .nm{font-family:var(--mono);font-size:12.5px;color:var(--fg);display:block;margin-bottom:4px;overflow-wrap:anywhere}
282.comm .nm:hover{color:var(--accent);text-decoration:none}
283.comm .by{font-family:var(--mono);font-size:11px;color:var(--mute);margin-top:6px}
284.comm .by a{color:var(--fg-2)}
285
286/* ── cta / footer ────────────────────────────────────────────────── */
287.cta{text-align:center;padding:clamp(44px,6vw,80px) 0}
288.cta h2{font-size:clamp(28px,4vw,46px)}
289.cta .btns{justify-content:center;margin-top:26px}
290footer{border-top:1px solid var(--line-3);padding:26px 0 44px;display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;font-family:var(--mono);font-size:12px;color:var(--mute);align-items:center}
291footer .fb{display:flex;align-items:center;gap:9px}
292footer .fb .logo{width:18px;height:18px;flex:0 0 auto}
293footer .fl{display:flex;gap:16px;flex-wrap:wrap}
294footer a{color:var(--fg-2)}
295#omarchy-note{color:var(--accent)}
296.vh{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
297</style>
298</head>
299<body>
300<canvas id="bg" aria-hidden="true"></canvas>
301<div id="tip" role="tooltip" aria-hidden="true"></div>
302
303<div class="shell">
304
305<header class="top">
306  <div class="top-in">
307    <a href="#top" class="brand"><svg class="logo" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><rect width="48" height="48" rx="7.2" fill="var(--accent)"/><g fill="var(--bg)"><rect x="5" y="20" width="5" height="8"/><rect x="12" y="12" width="5" height="24"/><rect x="19" y="4" width="5" height="40"/><rect x="26" y="14" width="5" height="20"/><rect x="33" y="18" width="5" height="12"/><rect x="40" y="22" width="3" height="4"/></g></svg>CLI<b>AMP</b></a>
308    <nav class="nav" aria-label="Primary navigation">
309      <a href="#install">install</a>
310      <a href="#sources" class="opt">sources</a>
311      <a href="#features" class="opt">features</a>
312      <a href="#radio">radio</a>
313      <a href="#stats">stats</a>
314      <a href="#keys" class="opt">keys</a>
315      <a href="#plugins" class="opt">plugins</a>
316      <a href="https://docs.cliamp.stream">docs ↗</a>
317      <a href="https://github.com/cliamp/cliamp-mobile">android ↗</a>
318      <a href="https://discord.gg/4VpCzXPuj2">discord ↗</a>
319      <a href="https://github.com/bjarneo/cliamp" class="cta">github ↗</a>
320    </nav>
321  </div>
322</header>
323
324<noscript><p>The live radio player and listener statistics on this page need JavaScript.</p></noscript>
325
326<main class="wrap">
327
328  <!-- ═══ HERO ═══ -->
329  <section class="hero" id="top">
330    <div>
331      <div class="eyebrow">~/audio/cliamp</div>
332      <h1>cliamp</h1>
333      <p class="tagline">A full <em>music player</em> that lives in your terminal. No browser tab required.</p>
334      <p class="lede">Playlists, a 10-band equalizer, 31 visualizers, synced lyrics, podcasts, remote control, and Lua plugins. Stream from <b>Spotify, Qobuz, Tidal, YouTube Music, Plex, Jellyfin, Emby, Navidrome, Lyrion, Yandex Music</b> — plus podcast feeds and about 58,000 internet radio stations.</p>
335      <div class="btns">
336        <a href="#install" class="btn go">$ install</a>
337        <a href="https://github.com/bjarneo/cliamp" class="btn">view source</a>
338      </div>
339      <div class="chips" aria-label="Project details">
340        <span>Linux · macOS · Windows</span>
341        <span><a href="https://github.com/cliamp/cliamp-mobile">Android ↗ cliamp mobile</a></span>
342        <span>MIT licensed</span>
343        <span>Go 1.26</span>
344      </div>
345    </div>
346
347    <div>
348      <div class="tty-cap"><span>$ cliamp --provider radio</span><b>plays live in your browser</b></div>
349      <div class="tty" id="player">
350        <div class="tty-bar">
351          <span class="dots"><span></span><span></span><span></span></span>
352          <span>tty1</span>
353          <span class="brandline">C L I A M P</span>
354        </div>
355        <div class="tty-body">
356          <div class="t-title"><span class="note">♫</span><span id="t-name">Lofi</span></div>
357          <div id="t-meta">pick a station below and press play</div>
358          <div class="t-status">
359            <span id="t-elapsed">00:00</span>
360            <span id="t-state">■ stopped</span>
361            <span id="t-live">live stream</span>
362          </div>
363          <canvas id="viz" height="180" aria-hidden="true"></canvas>
364          <div class="t-eqvol">
365            <span>EQ <span class="lab">[Rock]</span></span>
366            <span id="eq" aria-hidden="true"></span>
367            <label class="vol">VOL
368              <input id="vol" type="range" min="0" max="100" value="80" aria-label="Volume">
369              <span id="voldb">80</span>
370            </label>
371          </div>
372          <div class="t-ctl">
373            <button id="prev" type="button" class="tbtn" aria-label="Previous station">◀◀</button>
374            <button id="play" type="button" class="tbtn play">▶ play</button>
375            <button id="next" type="button" class="tbtn" aria-label="Next station">▶▶</button>
376            <span class="t-src">SRC <b>[Radio]</b> <span id="t-idx">1/14</span></span>
377          </div>
378          <div class="t-plhead"><span>▸─ Playlist</span><span>[Shuffle: Off]</span><span>[Repeat: All]</span></div>
379          <div id="t-list" aria-label="cliamp radio stations"></div>
380        </div>
381      </div>
382      <div class="tty-hint"><code id="t-cmd"><span class="p">$ </span>cliamp https://radio.cliamp.stream/lofi/stream.pls</code><span>space · &lt; &gt;</span></div>
383    </div>
384  </section>
385
386  <!-- ═══ INSTALL ═══ -->
387  <section id="install">
388    <h2>Install</h2>
389    <p class="sub">One line. Click any command to copy it.</p>
390    <div class="grid g-install">
391      <button type="button" class="copy-card" data-copy="curl -fsSL https://cliamp.stream/install.sh | sh">
392        <span class="cc-head"><span>LINUX / MACOS</span><span data-copylabel>copy</span></span>
393        <span class="cc-cmd">curl -fsSL https://cliamp.stream/install.sh | sh</span>
394      </button>
395      <button type="button" class="copy-card" data-copy="brew install bjarneo/cliamp/cliamp">
396        <span class="cc-head"><span>HOMEBREW</span><span data-copylabel>copy</span></span>
397        <span class="cc-cmd">brew install bjarneo/cliamp/cliamp</span>
398      </button>
399      <button type="button" class="copy-card" data-copy="yay -S cliamp">
400        <span class="cc-head"><span>ARCH (AUR)</span><span data-copylabel>copy</span></span>
401        <span class="cc-cmd">yay -S cliamp</span>
402      </button>
403      <button type="button" class="copy-card" data-copy="nix run github:bjarneo/cliamp">
404        <span class="cc-head"><span>NIX</span><span data-copylabel>copy</span></span>
405        <span class="cc-cmd">nix run github:bjarneo/cliamp</span>
406      </button>
407      <button type="button" class="copy-card" data-copy="go install github.com/bjarneo/cliamp@latest">
408        <span class="cc-head"><span>GO</span><span data-copylabel>copy</span></span>
409        <span class="cc-cmd">go install github.com/bjarneo/cliamp@latest</span>
410      </button>
411    </div>
412    <p class="note">then just run <b>cliamp</b> — <b>cliamp setup</b> walks you through streaming services and media servers. Optional: <i>ffmpeg</i> for AAC, ALAC, Opus and WMA, <i>yt-dlp</i> for YouTube, SoundCloud, Mixcloud, Bandcamp, Bilibili and NetEase. Windows builds are in <a href="https://github.com/bjarneo/cliamp/releases/latest">Releases</a>.</p>
413  </section>
414
415  <!-- ═══ SOURCES ═══ -->
416  <section id="sources">
417    <div class="sec-head">
418      <div>
419        <h2>Every source, one interface</h2>
420        <p class="sub">Streaming services, self-hosted libraries, local files, podcasts, and radio — all through the same playlist, EQ, visualizer, and lyrics pipeline.</p>
421      </div>
422      <div class="filters" id="filters" role="group" aria-label="Filter sources"></div>
423    </div>
424    <div class="grid g-sources" id="sources-grid"></div>
425    <p class="sub">Radio favorites follow the station: press <kbd>f</kbd> in Catalog, search results, or the playback playlist after browsing countries or genres. Stations are saved locally under Radio → Favorites by URL, using the station name rather than the current song and retaining station details, including the region, after restart. The header’s star badge counts all starred rows, including offscreen rows, with the same meaning as the row markers. Radio favorites do not reserve a star column in playlists containing no favorited stations. M3U/PLS wrappers keep their original station URL after playback resolution. Toggling a favorite preserves the selected station and completed catalog pages, including during loading. Favoriting does not fetch more catalog stations or interrupt an in-progress station load. IPC clients use the stable URL-based favorite IDs returned by provider.playlists; positional favorite IDs are not supported. On Linux and macOS, favorite mutations preserve other cliamp instances’ changes through a shared file lock. The action follows the displayed star: it favorites an unstarred station or unfavorites a starred station, without reversing the same change already made by another instance. Other instances’ changes become visible in another open player on its next favorite mutation or restart, not through live refresh. Windows assumes one writer per config directory. Press <kbd>f</kbd> again to remove one. In saved local playlists, <kbd>f</kbd> still bookmarks the track; <kbd>n</kbd> remains the separate heart favorite, available when the playback playlist has focus. Remote account favorites are unchanged. <a href="https://github.com/bjarneo/cliamp/blob/main/docs/radio.md#station-favorites">Radio favorites guide</a>.</p>
426    <p class="sub">Choose where TUI <kbd>Ctrl+S</kbd> saves yt-dlp downloads and temporary audio files: set <code>directory = "/media/usb/CLAPt/Music"</code> under <code>[downloads]</code> in config.toml and restart cliamp. Empty uses <code>~/Music/cliamp</code>. Relative paths are rejected (literal ~ is not expanded). Mount external drives first; missing directories are created without checking mount status. The setting also applies to IPC Save in the TUI; headless daemon saving is unchanged. Saved files can be opened locally and are not automatically substituted into online playlists.</p>
427    <p class="sub">
427Navidrome uses the player's transcoding settings, or streams the original file if none are configured. Set <code>format = "raw"</code> under <code>[navidrome]</code> to always request the original. <a href="https://github.com/bjarneo/cliamp/blob/main/docs/navidrome.md#transcoding">Navidrome guide</a>.</p>
428    <p class="sub">With Jellyfin as the default provider, reopen cliamp without files, URLs, or a playlist to restore the last track, position, and complete source list. Queued tracks keep their own album context. Press <kbd>Enter</kbd> to resume; restored sessions stay silent even with <code>auto_play</code>. Playback is checkpointed every two seconds and on normal exit, without saving unfinished seeks. Stream URLs use current API-key or password authentication when playback starts. <a href="https://github.com/bjarneo/cliamp/blob/main/docs/jellyfin.md">Jellyfin guide</a>.</p>
429    <p class="sub">Large Spotify playlists fill in as they load: the first tracks appear right away, later pages append in the background, and playback works while the list is still arriving. <a href="https://github.com/bjarneo/cliamp/blob/main/docs/spotify.md">Spotify guide</a>.</p>
430  </section>
431
432  <!-- ═══ FEATURES ═══ -->
433  <section id="features">
434    <h2>What's inside</h2>
435    <div class="grid g-feat">
436      <div class="feat">
437        <div class="feat-k">10-BAND EQ</div>
438        <p>Parametric equalizer with 14 presets — Rock, Jazz, Classical, Bass Boost, Vocal, Podcast and more — or dial in your own curve. The Custom preset survives restarts.</p>
439      </div>
440      <div class="feat">
441        <div class="feat-k">VISUALIZERS</div>
442        <p>31 built-in modes rendered in the terminal from live FFT data: spectrum bars, scope, wave, matrix, flame, and a true-stereo LED peak meter. ClassicPeak uses smooth bars and floating peak caps, with sampling aligned to audible playback and adaptive redraws for smooth motion. Neighboring bands are averaged into each bar. <kbd>v</kbd> cycles, <kbd>V</kbd> goes full screen.</p>
443      </div>
444      <div class="feat">
445        <div class="feat-k">SYNCED LYRICS</div>
446        <p>Embedded LRC first, then Spotify for signed-in Spotify tracks, with LRCLIB and NetEase fallback. Time-aligned lines scroll with song playback; fine-tune timing with <kbd>[</kbd>/<kbd>]</kbd> if a source is slightly off. Live radio refreshes on every ICY track change and uses manual scrolling, even with timestamped lyrics. Press <kbd>y</kbd>.</p>
447      </div>
448      <div class="feat">
449        <div class="feat-k">PLAYLISTS &amp; QUEUE</div>
450        <p>TOML playlists with live <b>[[dir]]</b> folder sources, M3U/M3U8/PLS import and export, cross-playlist favorites, a play-next queue that reads like the playlist and toggles in place with <kbd>A</kbd>, saved playlists you can append to what is playing or put the current track at the front of, and undo.</p>
451      </div>
452      <div class="feat">
453        <div class="feat-k">REMOTE CONTROL</div>
454        <p>Newline-delimited JSON over a local Unix socket, with full state snapshots and push events for scripts, status bars, and GUIs. Media keys via MPRIS on Linux and Now Playing on macOS. Run <b>--daemon</b> for headless playback.</p>
455      </div>
456      <div class="feat">
457        <div class="feat-k">THEMES</div>
458        <p>22 built-in color schemes that pass contrast checks — catppuccin, dracula, gruvbox, nord, tokyo-night, winamp and more — or drop a TOML file in <b>~/.config/cliamp/themes</b>. Press <kbd>t</kbd>.</p>
459      </div>
460      <div class="feat">
461        <div class="feat-k alt">PODCASTS</div>
462        <p>Apple's top charts and 19 genre categories, or any RSS feed. Browse episodes, subscribe locally with <kbd>f</kbd>, and, with ffmpeg installed, seek anywhere in an episode; the real length is measured from the file, not the feed. Episodes resume where you left off and are ticked once finished. <kbd>F</kbd> lists your subscriptions and pulls the newest episode of any show, or of all of them, without replacing the queue. No account or API key.</p>
463      </div>
464      <div class="feat">
465        <div class="feat-k">GAPLESS &amp; STREAMS</div>
466        <p>Preloads the next track for seamless transitions. Plays URLs, HLS (.m3u8) and internet radio with jitter buffering and live ICY metadata, and <b>cliamp://</b> links from a browser or chat.</p>
467      </div>
468    </div>
469  </section>
470
471  <!-- ═══ RADIO ═══ -->
472  <section id="radio">
473    <div class="sec-head">
474      <div>
475        <h2>cliamp radio</h2>
476        <p class="sub">Fourteen channels we run ourselves — always on, no account. Play any of them in the player above, or pipe the URL straight into cliamp or any player of your choice. In cliamp, a channel with a song library opens as a playlist of its songs.</p>
477      </div>
478      <a href="https://github.com/bjarneo/cliamp/blob/main/docs/radio.md" class="link-btn">read the radio docs ↗</a>
479    </div>
480    <div class="grid g-ch" id="channels"></div>
481  </section>
482
483  <!-- ═══ STATS ═══ -->
484  <section id="stats">
485    <div class="sec-head">
486      <div>
487        <h2>Who's listening right now</h2>
488        <p class="sub">Live connections to the cliamp radio channels and their playlists, by country. Drag the globe to spin it, or hover a country for its count.</p>
489      </div>
490      <div class="big" aria-live="polite">
491        <div><div class="n" id="n-listeners">–</div><div class="l">LISTENERS</div></div>
492        <div><div class="n fg" id="n-playlists">–</div><div class="l">ON PLAYLISTS</div></div>
493        <div><div class="n fg" id="n-countries">–</div><div class="l">COUNTRIES</div></div>
494      </div>
495    </div>
496    <p class="err" id="stats-err"></p>
497
498    <div class="grid g-stats">
499      <div class="globe-card">
500        <div class="card-bar"><span>$ cliamp radio --stats --globe</span><b id="globe-live">● live</b></div>
501        <div class="globe-wrap" id="globe-wrap">
502          <canvas id="globe" aria-label="Globe of current radio listeners by country. Drag to rotate."></canvas>
503          <div class="globe-empty" id="globe-empty"></div>
504        </div>
505      </div>
506
507      <div class="side">
508        <div class="side-h"><span id="countries-h">TOP COUNTRIES</span><span id="countries-u">LISTENERS</span></div>
509        <div id="countries"></div>
510      </div>
511    </div>
512  </section>
513
514  <!-- ═══ KEYS ═══ -->
515  <section id="keys" class="keys">
516    <div>
517      <h2>Keyboard first</h2>
518      <p class="sub">Everything is one keystroke away — no mouse, no menus. Vim-style <code>j</code>/<code>k</code> movement, <kbd>Tab</kbd> cycles the controls, and <kbd>?</kbd> or <kbd>Ctrl+K</kbd> shows the full keymap for the screen you are on. <a href="https://docs.cliamp.stream/docs/cliamp/keybindings">All keybindings ↗</a></p>
519    </div>
520    <div id="keys-grid" class="keys-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;align-content:start"></div>
521  </section>
522
523  <!-- ═══ PLUGINS ═══ -->
524  <section id="plugins">
525    <h2>Extend it with Lua</h2>
526    <p class="sub">Hook playback events, add commands and key bindings, scrobble, post to a status bar, or draw a visualizer — plugins drop into <code>~/.config/cliamp/plugins/</code> and run in sandboxed Lua 5.1 VMs. A crashing plugin never takes the player down.</p>
527    <div class="grid g-plug">
528      <div class="code">
529        <div class="card-bar">
530          <span class="mute">plugins/now-playing.lua</span>
531          <button type="button" data-copy="local p = plugin.register({
532    name = &quot;now-playing&quot;,
533    type = &quot;hook&quot;,
534})
535
536p:on(&quot;track.change&quot;, function(track)
537    cliamp.fs.write(&quot;/tmp/cliamp-now-playing&quot;,
538        track.artist .. &quot; - &quot; .. track.title)
539end)"><span data-copylabel>copy</span></button>
540        </div>
541<pre><span class="c">-- write the current track for Waybar, Polybar, tmux…</span>
542<span class="k">local</span> p = plugin.<span class="k">register</span>({
543    name = <span class="s">"now-playing"</span>,
544    type = <span class="s">"hook"</span>,
545})
546
547p:<span class="k">on</span>(<span class="s">"track.change"</span>, <span class="k">function</span>(track)
548    cliamp.fs.<span class="k">write</span>(<span class="s">"/tmp/cliamp-now-playing"</span>,
549        track.artist .. <span class="s">" - "</span> .. track.title)
550<span class="k">end</span>)</pre>
551      </div>
552      <div class="api">
553        <div class="api-i">
554          <div class="sig">track.change · playback.state · queue.end</div>
555          <div class="d">Twelve events to subscribe to: track changes, scrobbles, seeks, volume, EQ, shuffle and repeat, queue edits, end of queue, explicit stops, start and quit. Track-change events fire after playback starts successfully, including YouTube, SoundCloud, and gapless transitions. Buffering, failed, or superseded starts do not emit a track change. End-of-queue events report the last track that started playing; a manual stop, a stream still buffering, or a failed start does not emit one. Ask <code>cliamp.track.is_live()</code> before treating a track as one that ends.</div>
556        </div>
557        <div class="api-i">
558          <div class="sig">cliamp.queue.has_next()</div>
559          <div class="d">Reports whether another playable track exists, honoring the play-next queue, repeat, and shuffle.</div>
560        </div>
561        <div class="api-i">
562          <div class="sig">cliamp.queue.add({ path, title, artist, … })</div>
563          <div class="d">Queue a track exactly as described, such as a Spotify track with its title and artist, without resolving its path.</div>
564        </div>
565        <div class="api-i">
566          <div class="sig">p:bind("ctrl+n", fn) · p:command("name", fn)</div>
567          <div class="d">Register your own key bindings and shell-invokable commands.</div>
568        </div>
569        <div class="api-i">
570          <div class="sig">cliamp.http · cliamp.fs · cliamp.store · cliamp.timer</div>
571          <div class="d">Talk to any API, write files for status bars, persist state and schedule work — inside a sandbox with declared permissions.</div>
572        </div>
573        <div class="api-i">
574          <div class="sig">type = "visualizer" → p:render(bands, frame, rows, cols)</div>
575          <div class="d">Draw your own visualizer from the live spectrum bands. <a href="https://github.com/bjarneo/cliamp/blob/main/docs/plugins.md">Plugin API reference ↗</a></div>
576        </div>
577      </div>
578    </div>
579
580    <div class="community">
581      <div class="side-h"><span>COMMUNITY PLUGINS</span><span><code style="font-family:inherit;color:var(--fg-2)">cliamp plugins install user/cliamp-plugin-name</code></span></div>
582      <div class="grid g-comm" id="community"></div>
583    </div>
584  </section>
585
586  <!-- ═══ CTA ═══ -->
587  <section class="cta">
588    <h2>Close the tab. Play the music.</h2>
589    <div class="btns">
590      <button type="button" class="btn go" data-copy="curl -fsSL https://cliamp.stream/install.sh | sh"><span data-copylabel>$ curl -fsSL cliamp.stream/install.sh | sh</span></button>
591      <a href="https://github.com/bjarneo/cliamp" class="btn">star on github</a>
592    </div>
593  </section>
594
595  <footer>
596    <span class="fb">
596<svg class="logo" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><rect width="48" height="48" rx="7.2" fill="var(--accent)"/><g fill="var(--bg)"><rect x="5" y="20" width="5" height="8"/><rect x="12" y="12" width="5" height="24"/><rect x="19" y="4" width="5" height="40"/><rect x="26" y="14" width="5" height="20"/><rect x="33" y="18" width="5" height="12"/><rect x="40" y="22" width="3" height="4"/></g></svg>cliamp — MIT licensed. Built for people who live in the terminal.<span id="omarchy-note" hidden></span></span>
597    <span class="fl">
598      <a href="https://github.com/bjarneo/cliamp">github</a>
599      <a href="https://docs.cliamp.stream">docs</a>
600      <a href="https://x.com/iamdothash">@iamdothash</a>
601      <a href="https://contextowl.co">sponsored by contextowl.co</a>
602    </span>
603  </footer>
604</main>
605</div>
606
607<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-array.min.js"></script>
vendor: 1 bytes, line 607
607
608<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-geo.min.js"></script>
vendor: 1 bytes, line 608
608
609<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/topojson-client.min.js"></script>
vendor: 1 bytes, line 609
609
610<script>
611(function(){
612"use strict";
613var $=function(s,r){return (r||document).querySelector(s)};
614var $$=function(s,r){return Array.prototype.slice.call((r||document).querySelectorAll(s))};
615var reduce=!!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches);
616var STATS_URL="https://radio.cliamp.stream/statistics";
617var TRACK_STATS_URL="https://radio.cliamp.stream/tracks/statistics";
618var ATLAS_URL="https://cdn.jsdelivr.net/npm/[email protected]/countries-110m.json";
619var BASE="https://radio.cliamp.stream/";
620
621/* Each channel carries its own palette — playing it retints the page. */
622var STATIONS=[
623  {slug:"lofi",name:"Lofi",tag:"lofi / chillhop",accent:"#74e08c",warm:"#e9b455"},
624  {slug:"synthwave",name:"Synthwave",tag:"retrowave",accent:"#c88ce0",warm:"#5fb8e8"},
625  {slug:"edm",name:"EDM",tag:"electronic",accent:"#5fd0e8",warm:"#7d8ce8"},
626  {slug:"omarchy",name:"Omarchy",tag:"community",accent:"#8fa8ee",warm:"#b48be0"},
627  {slug:"chiptune",name:"Chiptunes",tag:"8-bit",accent:"#a8e05f",warm:"#e8d15f"},
628  {slug:"amiga",name:"Amiga",tag:"mod / tracker",accent:"#e8a05f",warm:"#e8735f"},
629  {slug:"ncs",name:"NCS",tag:"NoCopyrightSounds",accent:"#f0707e",warm:"#f0a35f"},
630  {slug:"ncs-house",name:"NCS House",tag:"house",accent:"#5fe0c8",warm:"#5fb8e8"},
631  {slug:"ncs-dubstep",name:"NCS Dubstep",tag:"dubstep",accent:"#a08cff",warm:"#c88ce0"},
632  {slug:"ncs-dnb",name:"NCS Drum & Bass",tag:"drum & bass",accent:"#7ee06a",warm:"#e8d15f"},
633  {slug:"ncs-trap",name:"NCS Trap",tag:"trap",accent:"#f0b45f",warm:"#f0735f"},
634  {slug:"ncs-phonk",name:"NCS Phonk",tag:"phonk",accent:"#f07ab8",warm:"#b48be0"},
635  {slug:"ncs-pop",name:"NCS Pop",tag:"pop",accent:"#5fc8f0",warm:"#f0a3c8"},
636  {slug:"ncs-chill",name:"NCS Chill",tag:"chill",accent:"#b8e05f",warm:"#5fe0c8"}
637];
638STATIONS.forEach(function(s){s.url=BASE+s.slug+"/stream";s.pls=s.url+".pls"});
639
640var SOURCES=[
641  {name:"Spotify",kind:"streaming",note:"Premium · OAuth"},
642  {name:"Qobuz",kind:"streaming",note:"lossless FLAC · OAuth"},
643  {name:"Tidal",kind:"streaming",note:"lossless FLAC · device code"},
644  {name:"YouTube",kind:"streaming",note:"search & play · yt-dlp"},
645  {name:"YouTube Music",kind:"streaming",note:"playlists & likes · cookies"},
646  {name:"SoundCloud",kind:"streaming",note:"search & genres · yt-dlp"},
647  {name:"Mixcloud",kind:"streaming",note:"shows & creators · yt-dlp"},
648  {name:"NetEase Cloud Music",kind:"streaming",note:"browser session · yt-dlp"},
649  {name:"Yandex Music",kind:"streaming",note:"OAuth token"},
650  {name:"Bandcamp",kind:"streaming",note:"paste a URL · yt-dlp"},
651  {name:"Bilibili",kind:"streaming",note:"video audio · yt-dlp"},
652  {name:"Plex",kind:"self-hosted",note:"server token"},
653  {name:"Jellyfin",kind:"self-hosted",note:"API key / password, gapless, resume"},
654  {name:"Emby",kind:"self-hosted",note:"API key"},
655  {name:"Navidrome",kind:"self-hosted",note:"Subsonic API · lyrics"},
656  {name:"Lyrion",kind:"self-hosted",note:"LMS"},
657  {name:"Audiobookshelf",kind:"self-hosted",note:"audiobooks & podcasts"},
658  {name:"Local files",kind:"local",note:"mp3 flac ogg opus wav aac alac wma"},
659  {name:"M3U / PLS",kind:"local",note:"local & remote playlists · HLS"},
660  {name:"SSH",kind:"local",note:"ssh:// paths, no mount needed"},
661  {name:"Internet radio",kind:"radio",note:"~58,000 stations · Radio Browser"},
662  {name:"cliamp radio",kind:"radio",note:"14 channels we run ourselves"},
663  {name:"Podcasts",kind:"podcast",note:"Apple charts + RSS · shorter episode titles · no account"}
664];
665var KIND={streaming:"var(--accent)","self-hosted":"var(--warm)",local:"var(--blue)",radio:"var(--rose)",podcast:"var(--purple)"};
666
667var KEYS=[
668  ["Space","play / pause"],["> / <","next / prev"],["+ / -","volume"],["← / →","seek 5s"],
669  ["e","EQ preset"],["v / V","visualizer / full screen"],["t","theme, or hide the title in full screen"],["y","lyrics"],
670  ["/","search playlist"],["Ctrl+F","search provider"],["z / r","shuffle / repeat"],["a","queue next"],
671  ["o","file browser"],["R / O","radio / podcasts"],["?","all keybindings"]
672];
673
674var COMMUNITY=[
675  {name:"cliamp-lastfm",url:"https://github.com/tetsuo76/cliamp-lastfm",desc:"Last.fm scrobbling",author:"tetsuo76"},
676  {name:"cliamp-plugin-led-burst",url:"https://github.com/AlexZeitler/cliamp-plugin-led-burst",desc:"Stereo LED matrix visualizer",author:"AlexZeitler"},
677  {name:"cliamp-plugin-nightrider",url:"https://github.com/HANCORE-linux/cliamp-plugin-nightrider",desc:"Nightrider visualizer",author:"HANCORE-linux"},
678  {name:"cliamp-plugin-tubeamp",url:"https://github.com/8bit64k/cliamp-plugin-tubeamp",desc:"Vacuum-tube amplifier visualizer",author:"8bit64k"},
679  {name:"cliamp-plugin-nova",url:"https://github.com/8bit64k/cliamp-plugin-nova",desc:"Concentric-ring spectrum visualizer",author:"8bit64k"},
680  {name:"cliamp-plugin-gpodder-sync",url:"https://github.com/sollymay/cliamp-plugin-gpodder-sync",desc:"gpodder.net subscription, progress, and download sync",author:"sollymay"},
681  {name:"cliamp-plugin-discord-rpc",url:"https://github.com/fazaimron27/cliamp-plugin-discord-rpc",desc:"Discord Rich Presence",author:"fazaimron27"},
682  {name:"herdr-cliamp",url:"https://github.com/coryshaw1/herdr-cliamp",desc:"Floating cliamp for the herdr multiplexer, with now-playing and transport key bindings",author:"coryshaw1"}
683];
684
685/* ISO 3166-1 alpha-2 → numeric, for the countries in world-atlas 110m. */
686var A2NUM={AE:784,AF:4,AL:8,AM:51,AO:24,AQ:10,AR:32,AT:40,AU:36,AZ:31,BA:70,BD:50,BE:56,BF:854,BG:100,BI:108,BJ:204,BN:96,BO:68,BR:76,BS:44,BT:64,BW:72,BY:112,BZ:84,CA:124,CD:180,CF:140,CG:178,CH:756,CI:384,CL:152,CM:120,CN:156,CO:170,CR:188,CU:192,CY:196,CZ:203,DE:276,DJ:262,DK:208,DO:214,DZ:12,EC:218,EE:233,EG:818,EH:732,ER:232,ES:724,ET:231,FI:246,FJ:242,FK:238,FR:250,GA:266,GB:826,GE:268,GH:288,GL:304,GM:270,GN:324,GQ:226,GR:300,GT:320,GW:624,GY:328,HN:340,HR:191,HT:332,HU:348,ID:360,IE:372,IL:376,IN:356,IQ:368,IR:364,IS:352,IT:380,JM:388,JO:400,JP:392,KE:404,KG:417,KH:116,KP:408,KR:410,KW:414,KZ:398,LA:418,LB:422,LK:144,LR:430,LS:426,LT:440,LU:442,LV:428,LY:434,MA:504,MD:498,ME:499,MG:450,MK:807,ML:466,MM:104,MN:496,MR:478,MW:454,MX:484,MY:458,MZ:508,NA:516,NC:540,NE:562,NG:566,NI:558,NL:528,NO:578,NP:524,NZ:554,OM:512,PA:591,PE:604,PG:598,PH:608,PK:586,PL:616,PR:630,PS:275,PT:620,PY:600,QA:634,RO:642,RS:688,RU:643,RW:646,SA:682,SB:90,SD:729,SE:752,SI:705,SK:703,SL:694,SN:686,SO:706,SR:740,SS:728,SV:222,SY:760,SZ:748,TD:148,TF:260,TG:768,TH:764,TJ:762,TL:626,TM:795,TN:788,TR:792,TT:780,TW:158,TZ:834,UA:804,UG:800,US:840,UY:858,UZ:860,VE:862,VN:704,VU:548,YE:887,ZA:710,ZM:894,ZW:716};
687
688/* ---------- theme ---------- */
689/* The playing channel sets --st-* on <html>; the stylesheet turns those into
690   --accent/--warm unless the Omarchy desktop palette is present, in which case
691   the desktop wins. The canvases read the effective colours back from CSS so
692   both paths stay in sync. */
693var accent=STATIONS[0].accent,warm=STATIONS[0].warm,bg="#0b0c0d",fg="#e9ece9";
694var HEX=/^#[0-9a-f]{6}$/i;
695function rgbArr(hex){return [1,3,5].map(function(i){return parseInt(hex.substr(i,2),16)})}
696function rgb(hex){return rgbArr(hex).join(",")}
697function toHex(arr){return "#"+arr.map(function(v){v=Math.max(0,Math.min(255,Math.round(v)));return (v<16?"0":"")+v.toString(16)}).join("")}
698function mix(a,b,t){var x=rgbArr(a),y=rgbArr(b);return toHex([0,1,2].map(function(i){return x[i]+(y[i]-x[i])*t}))}
699function lighten(hex){return mix(hex,"#ffffff",.38)}
700function cssHex(name,fallback){var v=getComputedStyle(document.documentElement).getPropertyValue(name).trim();return HEX.test(v)?v.toLowerCase():fallback}
701function omarchyActive(){return !!document.documentElement.dataset.omarchyTheme}
702function syncPalette(){
703  accent=cssHex("--accent",accent);warm=cssHex("--warm",warm);
704  bg=cssHex("--bg","#0b0c0d");fg=cssHex("--fg","#e9ece9");
705  var meta=document.querySelector('meta[name="theme-color"]');if(meta)meta.setAttribute("content",bg);
706  updateFavicon();
707}
708var FAVICON_BARS='<rect x="5" y="20" width="5" height="8"/><rect x="12" y="12" width="5" height="24"/><rect x="19" y="4" width="5" height="40"/><rect x="26" y="14" width="5" height="20"/><rect x="33" y="18" width="5" height="12"/><rect x="40" y="22" width="3" height="4"/>';
709function updateFavicon(){
710  var link=$("#favicon");if(!link)return;
711  var svg='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="7.2" fill="'+accent+'"/><g fill="'+bg+'">'+FAVICON_BARS+'</g></svg>';
712  var href="data:image/svg+xml,"+encodeURIComponent(svg);
713  if(link.getAttribute("href")!==href)link.setAttribute("href",href);
714}
715function applyTheme(a,w){
716  var st=document.documentElement.style;
717  st.setProperty("--st-accent",a);st.setProperty("--st-accent-hi",lighten(a));st.setProperty("--st-warm",w);
718  syncPalette();
719}
720function onOmarchyTheme(){
721  syncPalette();
722  var note=$("#omarchy-note"),root=document.documentElement;
723  if(omarchyActive()){note.textContent=" · following your Omarchy theme: "+root.dataset.omarchyTheme;note.hidden=false}
724  else{note.textContent="";note.hidden=true}
725  if(reduce){paintViz();paintBg();globe.redraw()}
726}
727
728/* ---------- copy ---------- */
729function legacyCopy(text){
730  try{
731    var ta=document.createElement("textarea");ta.value=text;ta.setAttribute("readonly","");
732    ta.style.position="fixed";ta.style.opacity="0";document.body.appendChild(ta);ta.select();
733    document.execCommand("copy");document.body.removeChild(ta);
734  }catch(e){}
735}
736function copyText(text,label){
737  var flash=function(){if(!label)return;var prev=label.textContent;label.textContent="copied ✓";setTimeout(function(){label.textContent=prev},1400)};
738  if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(text).then(flash,function(){legacyCopy(text);flash()})}
739  else{legacyCopy(text);flash()}
740}
741function bindCopy(){
742  $$("[data-copy]").forEach(function(b){
743    if(b._copyBound)return;b._copyBound=true;
744    b.addEventListener("click",function(){copyText(b.getAttribute("data-copy"),b.querySelector("[data-copylabel]"))});
745  });
746}
747
748/* ---------- player ---------- */
749var audio=null,actx=null,analyser=null,bins=null,corsOK=true;
750var playing=false,idx=0,t0=0,acc=0,armed=false,favs={};
751var counts={};
752
753function newAudio(){
754  if(audio){audio.onerror=audio.onplaying=audio.onwaiting=null;audio.pause();audio.removeAttribute("src");try{audio.load()}catch(e){}}
755  if(actx){try{actx.close()}catch(e){}actx=null;analyser=null}
756  audio=new Audio();
757  if(corsOK)audio.crossOrigin="anonymous";
758  audio.preload="none";
759  audio.volume=$("#vol").value/100;
760  audio.onplaying=function(){setState("▶ playing","var(--accent)")};
761  audio.onwaiting=function(){setState("… buffering","var(--warm)")};
762  audio.onerror=onAudioError;
763}
764function onAudioError(){
765  if(corsOK){
766    /* The stream refused a CORS fetch: retry plain (no analyser, idle bars). */
767    corsOK=false;var was=playing;newAudio();
768    if(was)startPlay();
769    return;
770  }
771  playing=false;
772  setState("! stream unreachable","var(--rose)");
773  $("#play").textContent="▶ play";
774  $("#t-meta").textContent="that station didn't respond — try another";
775  paintList();paintChannels();
776}
777function ensureAnalyser(){
778  if(analyser||!corsOK)return;
779  try{
780    var C=window.AudioContext||window.webkitAudioContext;if(!C)return;
781    actx=new C();
782    var src=actx.createMediaElementSource(audio);
783    analyser=actx.createAnalyser();
783analyser.fftSize=128;analyser.smoothingTimeConstant=.8;
784    src.connect(analyser);analyser.connect(actx.destination);
785    bins=new Uint8Array(analyser.frequencyBinCount);
786  }catch(e){analyser=null}
787  if(actx&&actx.state==="suspended")actx.resume().catch(function(){});
788}
789function stopAudio(){
790  if(!audio)return;
791  audio.pause();audio.removeAttribute("src");try{audio.load()}catch(e){}
792  playing=false;acc=0;
793}
794function startPlay(){
795  var s=STATIONS[idx];
796  if(!audio)newAudio();
797  audio.src=s.url;
798  ensureAnalyser();
799  $("#t-meta").textContent=s.tag+" · live stream";
800  setState("… buffering","var(--warm)");
801  acc=0;t0=Date.now();
802  var p=audio.play();
803  if(!p||!p.then)return;
804  p.then(function(){
805    playing=true;t0=Date.now();
806    setState("▶ playing","var(--accent)");
807    $("#play").textContent="■ stop";
808    $("#bg").style.opacity="1";
809    paintList();paintChannels();mediaSession(s);
810  }).catch(function(){
811    playing=false;
812    setState("! blocked","var(--rose)");
813    $("#t-meta").textContent="playback blocked — click play again";
814    $("#play").textContent="▶ play";
815    paintList();paintChannels();
816  });
817}
818function select(i,autoplay){
819  var was=playing;
820  idx=(i+STATIONS.length)%STATIONS.length;
821  var s=STATIONS[idx];
822  $("#t-name").textContent=s.name;
823  $("#t-meta").textContent=s.tag+" · live stream";
824  var cmd=$("#t-cmd");cmd.textContent="";
825  var p=document.createElement("span");p.className="p";p.textContent="$ ";cmd.appendChild(p);cmd.appendChild(document.createTextNode("cliamp "+s.pls));
826  applyTheme(s.accent,s.warm);
827  if(was||autoplay){stopAudio();startPlay()}
828  paintList();paintChannels();updateLive();
829  if(reduce){paintViz();paintBg()}
830}
831function toggle(){
832  armed=true;
833  if(playing){
834    stopAudio();
835    setState("■ stopped","");
836    $("#play").textContent="▶ play";
837    $("#bg").style.opacity=".45";
838    paintList();paintChannels();
839  }else startPlay();
840}
841function step(d){armed=true;select(idx+d,true)}
842function setState(text,color){var el=$("#t-state");el.textContent=text;el.style.color=color||""}
843function pad(n){return (n<10?"0":"")+n}
844function updateClock(){
845  var ms=acc+(playing?Date.now()-t0:0),s=Math.floor(ms/1000);
846  $("#t-elapsed").textContent=pad(Math.floor(s/60))+":"+pad(s%60);
847}
848function mediaSession(s){
849  if(!("mediaSession" in navigator))return;
850  try{
851    navigator.mediaSession.metadata=new MediaMetadata({title:s.name,artist:"cliamp radio",album:s.tag});
852    navigator.mediaSession.setActionHandler("play",function(){if(!playing)startPlay()});
853    navigator.mediaSession.setActionHandler("pause",function(){if(playing)toggle()});
854    navigator.mediaSession.setActionHandler("stop",function(){if(playing)toggle()});
855    navigator.mediaSession.setActionHandler("nexttrack",function(){step(1)});
856    navigator.mediaSession.setActionHandler("previoustrack",function(){step(-1)});
857  }catch(e){}
858}
859function updateLive(){
860  var el=$("#t-live"),n=counts[STATIONS[idx].slug];
861  if(n>0){el.textContent="● "+n+" listening";el.classList.add("live")}
862  else{el.textContent="live stream";el.classList.remove("live")}
863}
864
865/* ---------- lists ---------- */
866function renderStations(){
867  var list=$("#t-list");list.textContent="";
868  STATIONS.forEach(function(s,i){
869    var b=document.createElement("button");b.type="button";b.className="st";b.dataset.i=i;
870    b.innerHTML='<span class="mark">▸</span><span class="num"></span><span class="nm"></span><span class="tag"></span><span class="cnt"></span><span class="fav" title="Favorite">☆</span>';
871    b.querySelector(".num").textContent=(i+1)+".";
872    b.querySelector(".nm").textContent=s.name;
873    b.querySelector(".tag").textContent=s.tag;
874    b.addEventListener("click",function(e){
875      armed=true;
876      if(e.target.closest(".fav")){favs[i]=!favs[i];paintList();return}
877      select(i,true);
878    });
879    list.appendChild(b);
880  });
881  paintList();
882}
883function paintList(){
884  $$("#t-list .st").forEach(function(b){
885    var i=+b.dataset.i,on=i===idx;
886    b.classList.toggle("on",on);
887    b.setAttribute("aria-current",on?"true":"false");
888    b.querySelector(".mark").textContent=on&&playing?"▶":"▸";
889    var f=b.querySelector(".fav");f.textContent=favs[i]?"★":"☆";f.classList.toggle("on",!!favs[i]);
890    var n=counts[STATIONS[i].slug];
891    b.querySelector(".cnt").textContent=n>0?"● "+n:"";
892  });
893  $("#t-idx").textContent=(idx+1)+"/"+STATIONS.length;
894}
895function renderChannels(){
896  var wrap=$("#channels");wrap.textContent="";
897  STATIONS.forEach(function(s,i){
898    var el=document.createElement("div");el.className="ch";el.style.setProperty("--c",s.accent);el.dataset.i=i;
899    el.innerHTML='<div class="ch-head"><span class="dot"></span><span class="ch-name"></span><span class="ch-tag"></span></div>'+
900      '<div class="ch-url"></div>'+
901      '<div class="ch-foot"><button type="button" class="mini pri" data-act="play">▶ play</button><button type="button" class="mini" data-act="copy">copy url</button><span class="cnt"></span></div>';
902    el.querySelector(".ch-name").textContent=s.name;
903    el.querySelector(".ch-tag").textContent=s.tag;
904    el.querySelector(".ch-url").textContent=s.url;
905    el.querySelector('[data-act="play"]').addEventListener("click",function(){
906      armed=true;
907      if(i===idx&&playing){toggle();return}
908      select(i,true);
909      var hero=$("#top");
910      window.scrollTo({top:Math.max(0,hero.getBoundingClientRect().top+window.pageYOffset-70),behavior:reduce?"auto":"smooth"});
911    });
912    var cp=el.querySelector('[data-act="copy"]');
913    cp.addEventListener("click",function(){
914      copyText(s.url,null);
915      cp.textContent="copied ✓";cp.style.color=s.accent;
916      setTimeout(function(){cp.textContent="copy url";cp.style.color=""},1400);
917    });
918    wrap.appendChild(el);
919  });
920  paintChannels();
921}
922function paintChannels(){
923  $$("#channels .ch").forEach(function(el){
924    var i=+el.dataset.i,s=STATIONS[i],n=counts[s.slug],on=i===idx&&playing;
925    el.classList.toggle("on",on);
926    el.querySelector('[data-act="play"]').textContent=on?"■ stop":"▶ play";
927    var c=el.querySelector(".cnt");
928    if(n>0){c.textContent="● "+n+" listening";c.classList.add("live")}
929    else{c.textContent=n===0?"○ quiet right now":"";c.classList.remove("live")}
930  });
931}
932var filter="all";
933function renderFilters(){
934  var w=$("#filters");w.textContent="";
935  ["all","streaming","self-hosted","local","radio","podcast"].forEach(function(k){
936    var b=document.createElement("button");b.type="button";b.textContent=k;
937    b.className=k===filter?"on":"";b.setAttribute("aria-pressed",k===filter?"true":"false");
938    b.addEventListener("click",function(){filter=k;renderFilters();renderSources()});
939    w.appendChild(b);
940  });
941}
942function renderSources(){
943  var w=$("#sources-grid");w.textContent="";
944  SOURCES.filter(function(s){return filter==="all"||s.kind===filter}).forEach(function(s){
945    var el=document.createElement("div");el.className="src";el.style.setProperty("--c",KIND[s.kind]);
946    el.innerHTML='<div class="src-name"><span class="dot"></span><span class="nm"></span></div><div class="src-note"></div>';
947    el.querySelector(".nm").textContent=s.name;
948    el.querySelector(".src-note").textContent=s.note;
949    w.appendChild(el);
950  });
951}
952function renderKeys(){
953  var w=$("#keys-grid");w.textContent="";
954  KEYS.forEach(function(k){
955    var el=document.createElement("div");el.className="key";
956    el.innerHTML='<div class="k"></div><div class="d"></div>';
957    el.querySelector(".k").textContent=k[0];el.querySelector(".d").textContent=k[1];
958    w.appendChild(el);
959  });
960}
961function renderCommunity(){
962  var w=$("#community");w.textContent="";
963  COMMUNITY.forEach(function(c){
964    var el=document.createElement("div");el.className="comm";
965    el.innerHTML='<a class="nm"></a><span></span><div class="by">by <a></a></div>';
966    var a=el.querySelector(".nm");a.href=c.url;a.textContent=c.name;
967    el.querySelector("span").textContent=c.desc;
968    var b=el.querySelector(".by a");b.href="https://github.com/"+c.author;b.textContent="@"+c.author;
969    w.appendChild(el);
970  });
971}
972function renderEq(){
973  var w=$("#eq");w.textContent="";
974  [5,4,2,-1,-2,2,4,5,5,5].forEach(function(g){
975    var s=document.createElement("span");s.style.height=Math.max(3,(g+8)*1.4)+"px";
976    if(g<0)s.className="neg";w.appendChild(s);
977  });
978}
979
980/* ---------- visualizer + audio-reactive background ---------- */
981var vizC=$("#viz"),vg=vizC.getContext("2d"),bgC=$("#bg"),bgG=bgC.getContext("2d");
982var phase=0,tBg=0,bassSm=0,midSm=0,frameNo=0;
983function paintViz(){
984  var w=Math.max(2,Math.round((vizC.clientWidth||372)*2)),h=180,n=44,bw=w/n,vals=[],i;
985  if(vizC.width!==w)vizC.width=w;
986  vg.clearRect(0,0,w,h);
987  if(analyser&&playing){
988    analyser.getByteFrequencyData(bins);
989    for(i=0;i<n;i++)vals.push(bins[Math.floor(i*bins.length/n)]/255);
990  }else{
991    if(!reduce)phase+=.03;
992    for(i=0;i<n;i++){var idle=(Math.sin(phase+i*.42)+1)/2*.16+.04;vals.push(playing?idle*2:idle)}
993  }
994  for(i=0;i<n;i++){
995    var bh=Math.max(3,vals[i]*(h-12)),x=i*bw+1.5;
996    var grad=vg.createLinearGradient(0,h,0,h-bh);grad.addColorStop(0,accent);grad.addColorStop(1,warm);
997    vg.fillStyle=grad;vg.fillRect(x,h-bh,bw-3,bh);
998    vg.fillStyle="rgba("+rgb(accent)+",.16)";vg.fillRect(x,h-bh-5,bw-3,2.5);
999  }
1000}
1001function paintBg(){
1002  var de=document.documentElement,w=Math.max(320,de.clientWidth||window.innerWidth||1200),h=Math.max(320,de.clientHeight||window.innerHeight||800);
1003  if(bgC.width!==w||bgC.height!==h){bgC.width=w;bgC.height=h}
1004  bgG.clearRect(0,0,w,h);
1005  if(!reduce)tBg+=.012;
1006  var bass=0,mid=0,i;
1007  if(analyser&&playing){
1008    analyser.getByteFrequencyData(bins);
1009    var n=bins.length;
1010    for(i=0;i<n*.18;i++)bass+=bins[i];bass=bass/(n*.18)/255;
1011    for(i=Math.floor(n*.25);i<n*.6;i++)mid+=bins[i];mid=mid/(n*.35)/255;
1012  }
1013  bassSm=bassSm*.82+bass*.18;midSm=midSm*.86+mid*.14;
1014  var blob=function(x,y,r,color,a){
1015    var grd=bgG.createRadialGradient(x,y,0,x,y,r);
1016    grd.addColorStop(0,"rgba("+rgb(color)+","+a+")");grd.addColorStop(1,"rgba("+rgb(color)+",0)");
1017    bgG.fillStyle=grd;bgG.fillRect(0,0,w,h);
1018  };
1019  blob(w*(.24+Math.sin(tBg)*.05),h*(.22+Math.cos(tBg*.8)*.06),Math.min(w,h)*(.55+bassSm*.5),accent,.22+bassSm*.2);
1020  blob(w*(.8+Math.cos(tBg*.7)*.05),h*(.68+Math.sin(tBg*1.1)*.06),Math.min(w,h)*(.5+midSm*.45),warm,.08+midSm*.12);
1021  /* faint terminal grid, breathing with the low end */
1022  bgG.strokeStyle="rgba("+rgb(accent)+","+(.035+bassSm*.05)+")";bgG.lineWidth=1;
1023  var step=46,off=(tBg*22)%step,x,y;
1024  bgG.beginPath();
1025  for(x=-off;x<w;x+=step){bgG.moveTo(x,0);bgG.lineTo(x,h)}
1026  for(y=-off;y<h;y+=step){bgG.moveTo(0,y);bgG.lineTo(w,y)}
1027  bgG.stroke();
1028}
1029function loop(){
1030  paintViz();
1031  if(frameNo++%2===0)paintBg();
1032  requestAnimationFrame(loop);
1033}
1034
1035/* ---------- live statistics ---------- */
1036function fmt(n){return Math.round(n).toLocaleString("en-US")}
1037function getJSON(url){
1038  return fetch(url,{cache:"no-store"}).then(function(r){if(!r.ok)throw new Error(String(r.status));return r.json()});
1039}
1040function loadStats(){
1041  /* Playlist listeners add to the radio numbers, so a failed request counts as nobody on the playlists. */
1042  Promise.all([getJSON(STATS_URL),getJSON(TRACK_STATS_URL).catch(function(){return {}})])
1043    .then(function(r){$("#stats-err").textContent="";renderStats(r[0],r[1])})
1044    .catch(function(){
1045      $("#stats-err").textContent="Live statistics from radio.cliamp.stream are unavailable right now.";
1046      globe.setEmpty("live statistics unavailable");
1047    });
1048}
1049function renderStats(d,t){
1050  var st=d.stations||{},ts=t.stations||{},now=0,onPlaylists=0,active={},alltime={};
1051  counts={};
1052  function addActive(k,name,n){if(!k)return;active[k]=active[k]||{code:k,name:name,n:0};active[k].n+=n}
1053  Object.keys(st).forEach(function(slug){
1054    var s=st[slug],a=s.active_listeners||0;
1055    counts[slug]=(counts[slug]||0)+a;now+=a;
1056    (s.active_listener_countries||[]).forEach(function(c){addActive(c.country_code,c.country,c.sessions||0)});
1057    (s.top_countries||[]).forEach(function(c){
1058      var k=c.country_code;if(!k)return;
1059      alltime[k]=alltime[k]||{code:k,name:c.country,n:0};alltime[k].n+=c.sessions||0;
1060    });
1061  });
1062  Object.keys(ts).forEach(function(slug){
1063    var s=ts[slug],a=s.active_listeners||0;
1064    counts[slug]=(counts[slug]||0)+a;now+=a;onPlaylists+=a;
1065    (s.active_listener_countries||[]).forEach(function(c){addActive(c.country_code,c.country,c.listeners||0)});
1066  });
1067  var byN=function(a,b){return b.n-a.n};
1068  var act=Object.keys(active).map(function(k){return active[k]}).sort(byN);
1069  var all=Object.keys(alltime).map(function(k){return alltime[k]}).sort(byN);
1070  var live=act.length>0,rows=live?act:all;
1071
1072  $("#n-listeners").textContent=fmt(now);
1073  $("#n-playlists").textContent=fmt(onPlaylists);
1074  $("#n-countries").textContent=fmt(act.length);
1075  $("#countries-h").textContent=live?"TOP COUNTRIES":"TOP COUNTRIES · ALL-TIME";
1076  $("#countries-u").textContent=live?"LISTENERS":"SESSIONS";
1077  $("#globe-live").textContent=live?"● live":"○ all-time";
1078  renderCountries(rows.slice(0,10));
1079
1080  paintList();paintChannels();updateLive();
1081  globe.setData(rows,live);
1082}
1083function renderCountries(rows){
1084  var wrap=$("#countries");wrap.textContent="";
1085  if(!rows.length){
1086    var e=document.createElement("div");e.className="row";e.innerHTML='<div class="nm" style="color:var(--mute)">nobody is tuned in right now</div>';
1087    wrap.appendChild(e);return;
1088  }
1089  var max=rows[0].n||1;
1090  rows.forEach(function(c){
1091    var el=document.createElement("div");el.className="row";
1092    el.innerHTML='<div><div class="nm"><span></span></div><div class="bar"><i></i></div></div><span class="v"></span>';
1093    el.querySelector(".nm span").textContent=c.name;
1094    el.querySelector(".bar i").style.width=Math.max(2,Math.round(c.n/max*100))+"%";
1095    el.querySelector(".v").textContent=fmt(c.n);
1096    wrap.appendChild(el);
1097  });
1098}
1099
1100/* ---------- listeners globe (d3-geo + world-atlas) ---------- */
1101var globe=(function(){
1102  var cv=$("#globe"),wrap=$("#globe-wrap"),empty=$("#globe-empty"),tip=$("#tip"),g=cv.getContext("2d");
1103  var dpr=Math.min(2,window.devicePixelRatio||1);
1104  var proj=null,path=null,grat=null,sphere={type:"Sphere"},features=null,byId={},marks=[],maxV=1;
1105  var W=0,H=0,R=0,cx=0,cy=0,rot=[-18,-14],dragging=false,last=null,t=0,live=true;
1106  var visible=true,raf=0,failed=false,pending=null,hover=false,lastDraw=0;
1107  var DOT_MIN=2.5,DOT_MAX=11;
1108
1109  function fail(msg){failed=true;cv.style.display="none";empty.textContent=msg;empty.classList.add("show")}
1110  function setEmpty(msg){if(failed)return;empty.textContent=msg||"";empty.classList.toggle("show",!!msg)}
1111  function init(){
1112    if(!(window.d3&&d3.geoOrthographic&&window.topojson)){fail("the globe needs d3-geo, which could not be loaded");return}
1113    proj=d3.geoOrthographic().clipAngle(90);path=d3.geoPath(proj,g);grat=d3.geoGraticule10();
1114    resize();draw();
1115    fetch(ATLAS_URL).then(function(r){if(!r.ok)throw new Error(String(r.status));return r.json()}).then(function(topo){
1116      features=topojson.feature(topo,topo.objects.countries).features;
1117      if(pending){setData(pending.rows,pending.live);pending=null}
1118      resize();start();
1119    }).catch(function(){fail("could not load the world map")});
1120    if(window.ResizeObserver)new ResizeObserver(function(){resize();draw()}).observe(wrap);
1121    else window.addEventListener("resize",function(){resize();draw()});
1122    if("IntersectionObserver" in window){
1123      new IntersectionObserver(function(es){es.forEach(function(e){visible=e.isIntersecting;if(visible)start()})},{threshold:.05}).observe(wrap);
1124    }
1125    bindPointer();
1126  }
1127  function resize(){
1128    var r=wrap.getBoundingClientRect();
1129    W=Math.max(200,r.width);H=Math.max(200,r.height);
1130    cv.width=Math.round(W*dpr);cv.height=Math.round(H*dpr);
1131    g.setTransform(dpr,0,0,dpr,0,0);
1132    R=Math.min(W,H)/2-8;cx=W/2;cy=H/2;
1133    if(proj)proj.translate([cx,cy]).scale(R);
1134  }
1135  function setData(rows,isLive){
1136    live=isLive;
1137    if(!features){pending={rows:rows,live:isLive};return}
1138    byId={};maxV=1;
1139    rows.forEach(function(c){
1140      var n=A2NUM[c.code];if(n==null)return;
1141      var id=("00"+n).slice(-3);
1142      byId[id]={n:c.n,name:c.name};if(c.n>maxV)maxV=c.n;
1143    });
1144    marks=[];
1145    features.forEach(function(f){
1146      var v=byId[String(f.id)];if(!v)return;
1147      var c=d3.geoCentroid(f);if(!c||isNaN(c[0]))return;
1148      marks.push({lon:c[0],lat:c[1],v:v.n,name:(f.properties&&f.properties.name)||v.name});
1149    });
1150    marks.sort(function(a,b){return a.v-b.v});
1151    setEmpty(rows.length?"":"nobody is tuned in right now");
1152    draw();
1153  }
1154  function radiusFor(v){return DOT_MIN+Math.sqrt(v/maxV)*(DOT_MAX-DOT_MIN)}
1155  function draw(){
1156    if(!proj)return;
1157    proj.rotate(rot);
1158    g.clearRect(0,0,W,H);
1159    var a=rgb(accent);
1160    var ocean=mix(bg,fg,.03),land=mix(bg,fg,.07),landLit=mix(bg,fg,.12),coast=mix(bg,fg,.14);
1161    g.beginPath();path(sphere);g.fillStyle=ocean;g.fill();
1162    g.beginPath();path(grat);g.strokeStyle="rgba("+a+",.08)";g.lineWidth=.6;g.stroke();
1163    if(features){
1164      features.forEach(function(f){
1165        var lit=!!byId[String(f.id)];
1166        g.beginPath();path(f);
1167        g.fillStyle=lit?landLit:land;g.fill();
1168        g.strokeStyle=lit?"rgba("+a+",.32)":coast;g.lineWidth=.6;g.stroke();
1169      });
1170      var rr=d3.geoRotation(rot);
1171      marks.forEach(function(m){
1172        var rp=rr([m.lon,m.lat]);
1172if(Math.abs(rp[0])>89)return;
1173        var p=proj([m.lon,m.lat]);if(!p)return;
1174        var facing=Math.cos(rp[0]*Math.PI/180)*Math.cos(rp[1]*Math.PI/180),depth=.45+.55*Math.max(0,facing);
1175        var rad=radiusFor(m.v),pulse=reduce?1:1+.06*Math.sin(t*1.9+m.lon*.05);
1176        g.beginPath();g.arc(p[0],p[1],rad*2.4*pulse,0,Math.PI*2);g.fillStyle="rgba("+a+","+(.1*depth)+")";g.fill();
1177        g.beginPath();g.arc(p[0],p[1],rad*pulse,0,Math.PI*2);g.fillStyle="rgba("+a+","+(.9*depth)+")";g.fill();
1178        g.lineWidth=1;g.strokeStyle="rgba("+rgb(bg)+",.85)";g.stroke();
1179      });
1180    }
1181    g.beginPath();g.arc(cx,cy,R,0,Math.PI*2);g.strokeStyle="rgba("+a+",.28)";g.lineWidth=1;g.stroke();
1182  }
1183  function frame(now){
1184    raf=0;
1185    if(!visible||failed)return;
1186    if(now-lastDraw>=33){
1187      lastDraw=now;t+=.033;
1188      if(!dragging&&!hover&&!reduce)rot[0]+=.32;
1189      draw();
1190    }
1191    raf=requestAnimationFrame(frame);
1192  }
1193  function start(){if(!raf&&!failed&&features)raf=requestAnimationFrame(frame)}
1194  function label(name,n){return name+"  "+fmt(n)+(live?" listening":" sessions")}
1195  function bindPointer(){
1196    var up=function(){dragging=false;last=null;cv.classList.remove("drag")};
1197    cv.addEventListener("pointerdown",function(e){dragging=true;last=[e.clientX,e.clientY];cv.classList.add("drag");try{cv.setPointerCapture(e.pointerId)}catch(x){}});
1198    cv.addEventListener("pointerup",up);cv.addEventListener("pointercancel",up);
1199    cv.addEventListener("pointerenter",function(){hover=true});
1200    cv.addEventListener("pointerleave",function(){hover=false;tip.style.opacity=0;up()});
1201    cv.addEventListener("pointermove",function(e){
1202      if(dragging&&last){
1203        rot[0]+=(e.clientX-last[0])*.3;
1204        rot[1]=Math.max(-80,Math.min(80,rot[1]-(e.clientY-last[1])*.3));
1205        last=[e.clientX,e.clientY];tip.style.opacity=0;
1206        if(reduce)draw();
1207        return;
1208      }
1209      if(!features)return;
1210      var r=cv.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top;
1211      var rr=d3.geoRotation(rot),best=null,bestD=18,text=null;
1212      marks.forEach(function(m){
1213        if(Math.abs(rr([m.lon,m.lat])[0])>89)return;
1214        var p=proj([m.lon,m.lat]);if(!p)return;
1215        var d=Math.hypot(p[0]-mx,p[1]-my);
1216        if(d<bestD){bestD=d;best=m}
1217      });
1218      if(best)text=label(best.name,best.v);
1219      else if(Math.hypot(mx-cx,my-cy)<=R){
1220        var inv=proj.invert([mx,my]);
1221        if(inv&&!isNaN(inv[0])){
1222          for(var i=0;i<features.length;i++){
1223            if(d3.geoContains(features[i],inv)){
1224              var v=byId[String(features[i].id)];
1225              if(v)text=label((features[i].properties&&features[i].properties.name)||v.name,v.n);
1226              break;
1227            }
1228          }
1229        }
1230      }
1231      if(text){tip.textContent=text;tip.style.left=e.clientX+"px";tip.style.top=e.clientY+"px";tip.style.opacity=1}
1232      else tip.style.opacity=0;
1233    });
1234  }
1235  return {init:init,setData:setData,setEmpty:setEmpty,redraw:draw};
1236})();
1237
1238/* ---------- boot ---------- */
1239function init(){
1240  renderStations();renderChannels();renderFilters();renderSources();renderKeys();renderCommunity();renderEq();bindCopy();
1241  applyTheme(STATIONS[0].accent,STATIONS[0].warm);
1242  newAudio();
1243  $("#play").addEventListener("click",toggle);
1244  $("#next").addEventListener("click",function(){step(1)});
1245  $("#prev").addEventListener("click",function(){step(-1)});
1246  var vol=$("#vol");
1247  vol.addEventListener("input",function(){if(audio)audio.volume=vol.value/100;$("#voldb").textContent=vol.value});
1248  document.addEventListener("keydown",function(e){
1249    if(!armed||e.altKey||e.ctrlKey||e.metaKey)return;
1250    var t=e.target,tag=t&&t.tagName;
1251    if(/^(INPUT|TEXTAREA|SELECT|BUTTON|A)$/.test(tag)||(t&&t.isContentEditable))return;
1252    if(e.code==="Space"){e.preventDefault();toggle()}
1253    else if(e.key==="."||e.key===">")step(1);
1254    else if(e.key===","||e.key==="<")step(-1);
1255  });
1256  setInterval(updateClock,500);
1257  paintViz();paintBg();
1258  if(!reduce)requestAnimationFrame(loop);
1259  window.addEventListener("resize",function(){paintBg();paintViz()});
1260  globe.init();
1261  document.addEventListener("omarchythemechange",onOmarchyTheme);
1262  onOmarchyTheme();
1263  loadStats();
1264  setInterval(loadStats,15000);
1265}
1266init();
1267})();
1268</script>
1268
1269</body>
1270</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.