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 · < ></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 & 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 & 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 = "now-playing", 533 type = "hook", 534}) 535 536p:on("track.change", function(track) 537 cliamp.fs.write("/tmp/cliamp-now-playing", 538 track.artist .. " - " .. 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.