Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

2937 lines
120 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CLIAMP — Terminal Music Player</title>
<meta name="description" content="A retro terminal music player inspired by Winamp 2.x. Play local files, YouTube, Spotify, Qobuz, Plex, Jellyfin, Emby, Navidrome, SoundCloud, NetEase, and 30,000+ radio stations with a spectrum visualizer and 10-band EQ.">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="CLIAMP — Terminal Music Player">
<meta property="og:description" content="A retro terminal music player inspired by Winamp 2.x. Play YouTube, Spotify, Qobuz, Plex, Jellyfin, Emby, SoundCloud, NetEase, and 30,000+ radio stations from your terminal with a spectrum visualizer and 10-band EQ.">
<meta property="og:image" content="https://cliamp.stream/og-image.png">
<meta property="og:url" content="https://cliamp.stream">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="CLIAMP — Terminal Music Player">
<meta name="twitter:description" content="A retro terminal music player inspired by Winamp 2.x. Play YouTube, Spotify, Qobuz, Plex, Jellyfin, Emby, SoundCloud, NetEase, and 30,000+ radio stations from your terminal with a spectrum visualizer and 10-band EQ.">
<meta name="twitter:image" content="https://cliamp.stream/og-image.png">
<style>
/* ═══════════ RESET ═══════════ */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:auto;-webkit-text-size-adjust:100%}
/* ═══════════ TOKENS ═══════════ */
:root{
/* Palette */
--bg:#050507;
--bg-2:#08080b;
--surface:#0c0c11;
--surface-2:#111118;
--surface-3:#16161e;
--border:#1a1a22;
--border-hi:#272731;
--dim-2:#2a2a34;
--dim:#555;
--dim-bright:#7a7a85;
--fg:#b8b8c2;
--fg-hi:#e5e5ec;
--fg-max:#ffffff;
/* Accents */
--green:#00ff66;
--green-dim:rgba(0,255,102,.12);
--green-glow:rgba(0,255,102,.35);
--green-soft:#8fffb8;
--amber:#ffb300;
--amber-dim:rgba(255,179,0,.12);
--amber-glow:rgba(255,179,0,.3);
--cyan:#00cfff;
--cyan-dim:rgba(0,207,255,.12);
--purple:#c3a6ff;
--red:#ff4466;
--pink:#ff66cc;
/* Layout */
--wrap:960px;
}
body{
background:var(--bg);
color:var(--fg);
font-family:"SF Mono","Cascadia Code","Fira Code","JetBrains Mono","IBM Plex Mono","Consolas","Liberation Mono",monospace;
font-size:14px;
line-height:1.65;
overflow-x:hidden;
-webkit-font-smoothing:antialiased;
background-image:
radial-gradient(ellipse at 15% 0%,rgba(0,255,102,.035),transparent 55%),
radial-gradient(ellipse at 85% 100%,rgba(0,207,255,.025),transparent 55%);
background-attachment:fixed;
}
::selection{background:var(--green-dim);color:var(--fg-max)}
/* Subtle scanlines */
body::after{
content:"";position:fixed;inset:0;
background:repeating-linear-gradient(
0deg,
transparent 0 2px,
rgba(0,0,0,.04) 2px 3px
);
pointer-events:none;z-index:9999;
mix-blend-mode:multiply;
}
/* ═══════════ LAYOUT ═══════════ */
.wrap{max-width:var(--wrap);margin:0 auto;padding:0 24px;position:relative;z-index:1}
section{padding:72px 0;position:relative}
a{color:var(--cyan);text-decoration:none;transition:color .18s}
a:hover{color:var(--amber)}
/* ═══════════ NAV ═══════════ */
.nav{
position:sticky;top:0;z-index:100;
background:rgba(5,5,7,.85);
backdrop-filter:blur(12px) saturate(140%);
-webkit-backdrop-filter:blur(12px) saturate(140%);
border-bottom:1px solid var(--border);
}
.nav-inner{
max-width:var(--wrap);margin:0 auto;padding:0 24px;
display:flex;align-items:center;justify-content:space-between;
height:52px;
}
.nav-brand{
color:var(--green);font-size:13px;font-weight:700;
letter-spacing:2.5px;text-decoration:none;
display:inline-flex;align-items:center;gap:10px;
transition:text-shadow .25s;
}
.nav-brand-led{
width:7px;height:7px;border-radius:50%;
background:var(--green);
box-shadow:0 0 8px var(--green-glow);
animation:pulse-led 2.2s ease-in-out infinite;
}
.nav-brand:hover{text-shadow:0 0 14px var(--green-glow)}
.nav-links{display:flex;gap:0}
.nav-links a{
color:var(--dim);font-size:10px;letter-spacing:2px;
text-transform:uppercase;font-weight:600;
padding:7px 12px;
transition:color .18s;
}
.nav-links a:hover{color:var(--green)}
.nav-toggle{
display:none;background:transparent;
border:1px solid var(--dim-2);
color:var(--fg);width:32px;height:32px;cursor:pointer;
font-family:inherit;font-size:16px;
transition:all .18s;
}
.nav-toggle:hover{color:var(--green);border-color:var(--green)}
/* ═══════════ SECTION HEADER ═══════════ */
.sh{
display:flex;align-items:center;gap:14px;
margin-bottom:36px;
}
.sh-num{
color:var(--dim-bright);font-size:10px;letter-spacing:2px;
font-weight:700;
padding:4px 7px;
border:1px solid var(--border-hi);
background:var(--surface);
}
.sh-name{
color:var(--green);font-size:14px;font-weight:700;
letter-spacing:1px;
text-shadow:0 0 8px rgba(0,255,102,.15);
}
.sh-name::before{content:"/ ";color:var(--dim-2);font-weight:400}
.sh-line{
flex:1;height:1px;
background:linear-gradient(to right,var(--border-hi) 0%,var(--border) 60%,transparent 100%);
}
/* ═══════════ HERO ═══════════ */
.hero{
padding:56px 0 64px;
text-align:center;
overflow:hidden;
}
.hero-logo{
color:var(--green);
font-size:clamp(6px,1.55vw,13px);
line-height:1.12;
white-space:pre;
display:inline-block;
text-align:left;
text-shadow:0 0 20px var(--green-glow),0 0 60px rgba(0,255,102,.08);
animation:glow-pulse 4s ease-in-out infinite,fade-up .6s ease-out both;
letter-spacing:0;
margin-bottom:20px;
}
@keyframes glow-pulse{
0%,100%{text-shadow:0 0 20px var(--green-glow),0 0 60px rgba(0,255,102,.08)}
50%{text-shadow:0 0 34px var(--green-glow),0 0 90px rgba(0,255,102,.15)}
}
.hero-tagline{
color:var(--dim-bright);
font-size:11px;letter-spacing:4px;text-transform:uppercase;
margin-bottom:8px;font-weight:600;
}
.hero-tagline::before{content:"// ";color:var(--dim-2)}
.hero-desc{
color:var(--fg);font-size:13px;
max-width:480px;margin:0 auto 36px;
line-height:1.8;
}
.hero-desc em{color:var(--amber);font-style:normal}
/* ═══════════ CLIAMP TTY (terminal simulation) ═══════════ */
.tty{
margin:0 auto 36px;
max-width:760px;
background:#07070a;
border:1px solid var(--border-hi);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.02),
0 30px 80px rgba(0,0,0,.6),
0 0 0 1px rgba(0,0,0,.4);
text-align:left;
animation:fade-up .6s ease-out .1s both;
overflow:hidden;
}
/* Terminal titlebar — minimal prompt */
.tty-head{
display:flex;align-items:center;gap:10px;
padding:7px 14px;
background:linear-gradient(to bottom,#13131a,#0b0b11);
border-bottom:1px solid var(--border);
font-size:11px;
}
.tty-head-prompt{color:var(--green);font-weight:700}
.tty-head-cmd{color:var(--fg-hi);font-weight:600}
.tty-head-flag{color:var(--dim-bright);font-weight:400}
.tty-head-spacer{flex:1}
.tty-head-pid{color:var(--dim);font-size:10px;letter-spacing:1px}
/* Terminal body — monospace cliamp TUI */
.tty-body{
padding:18px 22px 16px;
font-size:13px;
line-height:1.55;
color:var(--fg);
font-family:inherit;
background:
radial-gradient(ellipse 140% 100% at 50% 0%,rgba(0,255,102,.02) 0%,transparent 60%),
#07070a;
position:relative;
}
.tty-body::after{
content:"";position:absolute;inset:0;pointer-events:none;
background:repeating-linear-gradient(0deg,transparent 0 2px,rgba(0,0,0,.12) 2px 3px);
opacity:.5;
}
.tty-body > *{position:relative;z-index:1}
.t-row{
display:flex;align-items:baseline;gap:10px;
white-space:nowrap;
}
.t-row-wrap{flex-wrap:wrap}
.t-row-between{justify-content:space-between}
.t-spacer{height:10px}
/* Colors matching cliamp's theme styles */
.t-title{
color:var(--green);font-weight:700;
letter-spacing:2px;
text-shadow:0 0 12px var(--green-glow);
}
.t-focus{
color:var(--dim-bright);font-weight:400;
margin-left:auto;
}
.t-track-row{gap:8px}
.t-track-sym{color:var(--green);flex-shrink:0}
.t-track{
color:var(--fg-hi);font-weight:500;
overflow:hidden;text-overflow:ellipsis;
flex:1;min-width:0;
}
.t-time-row{
cursor:pointer;
transition:background .18s;
margin-left:-8px;margin-right:-8px;
padding:2px 8px;
}
.t-time-row:hover{background:rgba(0,255,102,.04)}
.t-time{color:var(--dim-bright);font-weight:500}
.t-status{margin-left:auto;font-weight:600}
.t-status-stopped{color:var(--dim)}
.t-status-stopped em{color:var(--dim);font-style:normal;font-size:11px;opacity:.6}
.t-status-playing{color:var(--green);display:none;text-shadow:0 0 8px var(--green-glow)}
.t-status-loading{color:var(--amber);display:none;text-shadow:0 0 6px var(--amber-glow)}
.radio-player.playing .t-status-stopped,
.radio-player.loading .t-status-stopped{display:none}
.radio-player.playing .t-status-playing{display:inline}
.radio-player.loading .t-status-loading{display:inline}
.radio-player.loading .t-status-loading{animation:pulse-led 1s ease-in-out infinite}
/* Spectrum — tri-color gradient matching cliamp's low/mid/high */
#ascii-viz{
font-size:clamp(9px,1.3vw,13px);
line-height:1.0;
letter-spacing:1px;
user-select:none;
display:block;
text-align:center;
margin:6px 0;
background:linear-gradient(
to bottom,
var(--red) 0%,
var(--red) 18%,
var(--amber) 38%,
var(--amber) 58%,
var(--green) 80%,
var(--green) 100%
);
-webkit-background-clip:text;
background-clip:text;
-webkit-text-fill-color:transparent;
color:transparent;
filter:drop-shadow(0 0 5px rgba(0,255,102,.22));
}
/* Seek / streaming bar */
.t-seek{
display:flex;align-items:center;justify-content:center;
color:var(--amber);font-weight:600;
font-size:12px;letter-spacing:0;
text-shadow:0 0 6px var(--amber-glow);
margin-top:4px;
overflow:hidden;white-space:nowrap;
}
.t-seek-fill{color:var(--amber);opacity:.9;letter-spacing:-1px}
.t-seek-label{
color:var(--amber);font-weight:800;letter-spacing:2px;
padding:0 8px;
}
/* EQ + VOL row */
.t-eqvol{
display:flex;gap:16px;align-items:baseline;flex-wrap:wrap;
}
.t-eq-inline{
display:inline-flex;align-items:baseline;gap:6px;flex-wrap:wrap;
}
.t-eq-bands{
display:inline-flex;gap:5px;margin-left:4px;
color:var(--dim-bright);font-size:12px;font-variant-numeric:tabular-nums;
}
.t-vol-inline{
display:inline-flex;align-items:center;gap:8px;
margin-left:auto;flex:1;min-width:180px;max-width:280px;
}
.t-vol-db{font-variant-numeric:tabular-nums;color:var(--dim-bright)}
.t-label{color:var(--dim-bright);font-weight:600}
.t-dim{color:var(--dim-bright)}
.t-active{color:var(--amber);font-weight:700;text-shadow:0 0 6px var(--amber-glow)}
.t-fg{color:var(--fg-hi)}
/* Volume slider rendered as cliamp's VOL bar */
.rp-vol{
-webkit-appearance:none;appearance:none;
flex:1;height:13px;
background:transparent;
border:none;
outline:none;cursor:pointer;padding:0;
position:relative;
}
.rp-vol::-webkit-slider-runnable-track{
height:13px;background:transparent;
background-image:repeating-linear-gradient(to right,var(--dim-2) 0 2px,transparent 2px 3px);
background-size:100% 13px;
border-radius:0;
}
.rp-vol::-moz-range-track{
height:13px;background:transparent;
background-image:repeating-linear-gradient(to right,var(--dim-2) 0 2px,transparent 2px 3px);
}
.rp-vol::-webkit-slider-thumb{
-webkit-appearance:none;
width:4px;height:13px;
background:var(--green);
box-shadow:0 0 8px var(--green-glow),-300px 0 0 -4px var(--green);
border:none;cursor:pointer;border-radius:0;
margin-top:0;
}
.rp-vol::-moz-range-thumb{
width:4px;height:13px;
background:var(--green);
box-shadow:0 0 8px var(--green-glow),-300px 0 0 -4px var(--green);
border:none;cursor:pointer;border-radius:0;
}
.rp-vol::-moz-range-progress{
height:13px;background:var(--green);
box-shadow:0 0 8px var(--green-glow);
}
/* SRC pills row */
.t-src-row{
gap:6px;flex-wrap:wrap;
}
.rp-ch{
background:transparent;
border:none;
color:var(--dim-bright);
font-family:inherit;font-size:13px;
padding:0;margin:0;
cursor:pointer;transition:color .15s;
font-weight:500;
}
.rp-ch::before{content:"[";color:var(--dim-bright)}
.rp-ch::after{content:"]";color:var(--dim-bright)}
.rp-ch:hover{color:var(--fg-hi)}
.rp-ch.active{
color:var(--amber);font-weight:700;
text-shadow:0 0 8px var(--amber-glow);
}
.rp-ch.active::before,.rp-ch.active::after{color:var(--amber)}
.t-pill-static{
color:var(--dim-bright);font-weight:500;
}
/* Playlist header + items */
.t-pl-header{
color:var(--amber);font-weight:700;
gap:6px;flex-wrap:wrap;
text-shadow:0 0 6px var(--amber-glow);
}
.t-pl-header .t-dim{text-shadow:none;font-weight:400}
.t-pl-item{
white-space:nowrap;
overflow:hidden;text-overflow:ellipsis;
}
.t-pl-active{color:var(--fg-hi);font-weight:600}
.t-pl-active::first-letter{color:var(--green)}
/* Help line */
.t-help{
gap:14px;flex-wrap:wrap;
color:var(--dim-bright);font-size:12px;
margin-top:4px;
}
.t-help-item{display:inline-flex;align-items:baseline;gap:5px}
.t-help kbd{
color:var(--fg-hi);background:var(--dim-2);
padding:0 6px;font-family:inherit;font-size:11px;
font-weight:700;
border-radius:2px;
}
/* Bottom status line */
.t-bot{
color:var(--dim);font-size:12px;
margin-top:4px;
display:flex;justify-content:space-between;
white-space:nowrap;flex-wrap:wrap;gap:12px;
}
.t-bot-right{color:var(--dim-bright)}
/* Hint command below the tty */
.tty-hint{
text-align:center;
margin:0 auto 12px;
font-size:11px;color:var(--dim-bright);
letter-spacing:.3px;
}
.tty-hint code{
color:var(--green-soft);font-family:inherit;
}
.tty-hint code .p{color:var(--dim)}
@keyframes pulse-led{0%,100%{opacity:1}50%{opacity:.4}}
/* CTA */
.hero-cta{
display:flex;gap:12px;
justify-content:center;flex-wrap:wrap;
animation:fade-up .6s ease-out .2s both;
}
.btn{
display:inline-flex;align-items:center;gap:10px;
font-family:inherit;font-size:12px;letter-spacing:2px;
padding:11px 26px;
border:1px solid;cursor:pointer;
transition:all .2s;text-decoration:none;
text-transform:uppercase;font-weight:700;
position:relative;
}
.btn-primary{
background:var(--green-dim);
color:var(--green);border-color:var(--green);
box-shadow:inset 0 0 10px rgba(0,255,102,.08),0 0 0 1px rgba(0,255,102,.3);
}
.btn-primary:hover{
background:var(--green);color:var(--bg);
box-shadow:0 0 20px var(--green-glow);
}
.btn-ghost{
background:transparent;color:var(--fg);border-color:var(--border-hi);
}
.btn-ghost:hover{
color:var(--amber);border-color:var(--amber);
}
.btn-arrow{transition:transform .2s}
.btn:hover .btn-arrow{transform:translateX(3px)}
/* ═══════════ MARQUEE (providers) ═══════════ */
.marquee{
border-top:1px solid var(--border);
border-bottom:1px solid var(--border);
background:var(--surface);
padding:16px 0;
overflow:hidden;
mask-image:linear-gradient(to right,transparent 0,#000 4%,#000 96%,transparent 100%);
-webkit-mask-image:linear-gradient(to right,transparent 0,#000 4%,#000 96%,transparent 100%);
}
.marquee-track{
display:flex;gap:40px;
width:max-content;
animation:marquee 50s linear infinite;
color:var(--dim-bright);
font-size:12px;letter-spacing:3px;
text-transform:uppercase;font-weight:700;
}
.marquee-item{
display:inline-flex;align-items:center;gap:10px;
}
.marquee-item::before{
content:"▸";color:var(--amber);font-size:10px;
}
.marquee-item strong{color:var(--fg-hi);font-weight:700}
@keyframes marquee{
from{transform:translateX(0)}
to{transform:translateX(-50%)}
}
/* ═══════════ DEMO ═══════════ */
.demo-wrap{
position:relative;
border:1px solid var(--border-hi);
box-shadow:0 20px 50px rgba(0,0,0,.5);
}
.demo-video{
display:block;width:100%;height:auto;
background:var(--bg-2);
}
.sound-toggle{
position:absolute;bottom:14px;right:14px;
width:36px;height:36px;
background:rgba(5,5,7,.92);
backdrop-filter:blur(8px);
-webkit-backdrop-filter:blur(8px);
border:1px solid var(--border-hi);
cursor:pointer;
display:flex;align-items:center;justify-content:center;
color:var(--fg);transition:all .18s;
}
.sound-toggle:hover{border-color:var(--green);color:var(--green)}
.sound-toggle.unmuted{border-color:var(--green);color:var(--green);box-shadow:0 0 12px var(--green-dim)}
.sound-toggle svg{width:14px;height:14px;fill:currentColor}
.sound-toggle .icon-unmuted{display:none}
.sound-toggle.unmuted .icon-muted{display:none}
.sound-toggle.unmuted .icon-unmuted{display:block}
/* ═══════════ INSTALL ═══════════ */
.install-grid{
display:grid;
grid-template-columns:repeat(3,1fr);
gap:10px;
}
.install-box{
background:var(--surface);
border:1px solid var(--border);
padding:16px 18px;
position:relative;
transition:all .2s;
cursor:pointer;
display:flex;flex-direction:column;gap:10px;
}
.install-box:hover{
border-color:var(--green);
box-shadow:0 0 0 1px rgba(0,255,102,.15);
}
.install-platform{
color:var(--dim-bright);font-size:9px;letter-spacing:2px;
text-transform:uppercase;font-weight:700;
}
.install-platform::before{content:"[ ";color:var(--dim-2)}
.install-platform::after{content:" ]";color:var(--dim-2)}
.install-box code{
color:var(--green);font-size:12px;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
display:block;
font-family:inherit;
}
.install-box .prompt{color:var(--dim);user-select:none}
.install-box .copied-msg{
position:absolute;top:-24px;left:50%;
transform:translateX(-50%);
background:var(--green);color:var(--bg);
font-size:10px;letter-spacing:1.5px;font-weight:800;
padding:3px 10px;
opacity:0;transition:opacity .2s;
}
.install-box .copied-msg.show{opacity:1}
.install-note{
margin-top:18px;font-size:11px;
color:var(--dim-bright);letter-spacing:.5px;
}
.install-note a{color:var(--cyan)}
.install-note a:hover{color:var(--amber)}
/* First-run setup callout */
.next-step{
margin-top:28px;
padding:16px 18px;
border:1px solid var(--dim-2);
border-left:3px solid var(--amber);
background:rgba(255,170,0,0.04);
}
.next-step-label{
font-size:11px;color:var(--amber);
letter-spacing:1.5px;font-weight:700;
text-transform:uppercase;margin-bottom:8px;
}
.next-step h3{
font-size:15px;color:var(--bright);
margin:0 0 6px 0;font-weight:700;
}
.next-step p{
font-size:12px;color:var(--fg);
margin:0 0 12px 0;line-height:1.6;
}
.next-step .install-box{margin:0}
/* ═══════════ SOURCES (providers) ═══════════ */
.sources-intro{
color:var(--fg);font-size:13px;
margin-bottom:24px;line-height:1.7;max-width:600px;
}
.sources-intro em{color:var(--amber);font-style:normal}
.sources-grid{
display:grid;
grid-template-columns:repeat(4,1fr);
gap:8px;
}
.source{
background:var(--surface);
border:1px solid var(--border);
padding:18px 14px;
transition:all .2s;
position:relative;overflow:hidden;
display:flex;flex-direction:column;gap:8px;
}
.source::before{
content:"";position:absolute;left:0;top:0;bottom:0;
width:2px;background:var(--dim-2);transition:background .2s;
}
.source:hover{border-color:var(--border-hi);background:var(--surface-2)}
.source:hover::before{background:var(--src-color,var(--green))}
.source-badge{
display:inline-flex;align-items:center;gap:6px;
font-size:9px;letter-spacing:1.5px;
color:var(--dim-bright);font-weight:700;
text-transform:uppercase;
}
.source-badge::before{
content:"";width:5px;height:5px;border-radius:50%;
background:var(--src-color,var(--green));
box-shadow:0 0 4px var(--src-color,var(--green));
opacity:.7;
}
.source-name{
color:var(--fg-hi);font-size:14px;font-weight:700;
letter-spacing:.3px;
}
.source-desc{
color:var(--dim-bright);font-size:11px;
line-height:1.55;
}
.source-config{
margin-top:12px;
padding:16px 18px;
border:1px solid var(--dim-2);
border-left:3px solid #d33a31;
background:rgba(211,58,49,0.04);
}
.source-config h3{
font-size:15px;color:var(--bright);
margin:0 0 6px 0;font-weight:700;
}
.source-config p{
font-size:12px;color:var(--fg);
margin:0 0 12px 0;line-height:1.6;
}
.source-config p:last-child{margin-bottom:0}
.source-config code{color:var(--green);font-size:11px}
.source-config kbd{
color:var(--amber);font-family:inherit;
padding:1px 6px;border:1px solid var(--border-hi);
background:var(--surface);font-size:10px;
}
.source-config pre{
margin:0 0 12px 0;
padding:12px 14px;
background:var(--bg);
border:1px solid var(--border);
color:var(--green);
font-family:var(--font-mono);
font-size:11px;
line-height:1.5;
overflow:auto;
}
/* ═══════════ FEATURES ═══════════ */
.features-grid{
display:grid;
grid-template-columns:repeat(3,1fr);
gap:8px;
}
.feature{
background:var(--surface);
border:1px solid var(--border);
padding:18px;
transition:all .2s;
position:relative;
}
.feature::before{
content:"";position:absolute;left:0;top:0;bottom:0;
width:2px;background:var(--dim-2);transition:background .2s;
}
.feature:hover{background:var(--surface-2)}
.feature:hover::before{background:var(--green)}
.feature-icon{
font-size:15px;color:var(--amber);
margin-bottom:10px;line-height:1;
text-shadow:0 0 8px var(--amber-glow);
}
.feature-name{
color:var(--fg-hi);font-size:13px;
font-weight:700;margin-bottom:5px;
letter-spacing:.3px;
}
.feature p{
font-size:11px;color:var(--dim-bright);line-height:1.55;
}
.feature p code{color:var(--green);font-size:10px}
.feature p kbd{color:var(--amber);font-family:inherit;font-size:10px}
/* ═══════════ THEMES ═══════════ */
.themes-intro{
color:var(--fg);font-size:13px;
margin-bottom:24px;line-height:1.7;
}
.themes-intro kbd{
color:var(--amber);font-family:inherit;
padding:1px 6px;border:1px solid var(--border-hi);
background:var(--surface);font-size:11px;
}
.themes-intro code{color:var(--green);font-size:12px}
.themes-intro .path{color:var(--dim-bright)}
.themes-grid{
display:grid;
grid-template-columns:repeat(3,1fr);
gap:6px;
}
.theme-card{
background:var(--surface);
border:1px solid var(--border);
padding:12px 14px;
transition:all .2s;
cursor:default;
}
.theme-card:hover{
border-color:var(--border-hi);
background:var(--surface-2);
}
.theme-card-name{
color:var(--dim-bright);font-size:11px;font-weight:600;
margin-bottom:8px;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
letter-spacing:.3px;
}
.theme-card:hover .theme-card-name{color:var(--fg-hi)}
.theme-bars{
font-size:14px;letter-spacing:2px;
line-height:1;
}
.theme-bars span{transition:filter .2s}
.theme-card:hover .theme-bars span{filter:drop-shadow(0 0 4px currentColor)}
.themes-note{
margin-top:14px;font-size:10px;
color:var(--dim);letter-spacing:1px;
}
.themes-note::before{content:"# ";color:var(--dim-2)}
/* ═══════════ KEYBINDINGS ═══════════ */
.keys-groups{
display:grid;
gap:10px;
}
.keys-group{
background:var(--surface);
border:1px solid var(--border);
}
.keys-group-title{
padding:10px 16px;
background:var(--surface-2);
border-bottom:1px solid var(--border);
color:var(--amber);font-size:10px;letter-spacing:2px;
font-weight:800;text-transform:uppercase;
display:flex;align-items:center;gap:8px;
}
.keys-group-title::before{
content:"";width:5px;height:5px;border-radius:50%;
background:var(--amber);box-shadow:0 0 5px var(--amber-glow);
}
.keys-group-body{padding:4px 0}
.key-row{
display:flex;gap:14px;
padding:6px 16px;
transition:background .15s;
align-items:baseline;
}
.key-row:hover{background:var(--green-dim)}
.key-row kbd{
color:var(--green);font-family:inherit;font-size:12px;
min-width:120px;flex-shrink:0;
font-weight:600;
}
.key-row span{color:var(--dim-bright);font-size:11px;line-height:1.5}
/* ═══════════ CLI + REMOTE ═══════════ */
.cli-remote-grid{
display:grid;
grid-template-columns:repeat(3,1fr);
gap:20px;
}
.cli-remote-col{
background:var(--surface);
border:1px solid var(--border);
}
.cli-remote-title{
padding:10px 16px;
background:var(--surface-2);
border-bottom:1px solid var(--border);
color:var(--green);font-size:11px;letter-spacing:2px;
font-weight:800;text-transform:uppercase;
display:flex;align-items:center;gap:10px;
}
.cli-remote-title::before{
content:"$";color:var(--dim);font-weight:700;
}
.cli-remote-col .key-row kbd{min-width:170px;color:var(--amber)}
.cli-remote-col .key-row:hover{background:var(--amber-dim)}
.cli-remote-intro{
color:var(--fg);font-size:13px;
margin-bottom:20px;line-height:1.7;max-width:680px;
}
/* ═══════════ PLUGINS ═══════════ */
.plugins-intro{
color:var(--fg);font-size:13px;
margin-bottom:24px;line-height:1.7;max-width:680px;
}
.plugins-intro code{color:var(--green);font-size:12px}
.plugins-intro .path{color:var(--dim-bright)}
.plugin-examples{
display:grid;
grid-template-columns:repeat(2,1fr);
gap:12px;
margin-bottom:32px;
}
.plugin-examples .plugin-example:last-child{grid-column:1/-1}
.plugin-example{
background:var(--surface);
border:1px solid var(--border);
overflow:hidden;
display:flex;flex-direction:column;
}
.plugin-example-title{
padding:8px 14px;
background:var(--surface-2);
border-bottom:1px solid var(--border);
color:var(--amber);font-size:10px;letter-spacing:1.5px;
font-weight:700;text-transform:uppercase;
display:flex;align-items:center;gap:8px;
}
.plugin-example-title::before{
content:"●";color:var(--green);font-size:8px;
}
.plugin-example pre{
padding:14px 18px;margin:0;
color:var(--fg);font-size:11px;line-height:1.65;
overflow-x:auto;flex:1;
font-family:inherit;
}
.plugin-example pre .kw{color:var(--purple)}
.plugin-example pre .fn{color:var(--cyan)}
.plugin-example pre .str{color:var(--green)}
.plugin-example pre .cm{color:var(--dim)}
.plugin-example pre .op{color:var(--amber)}
.plugin-subhead{
color:var(--green);font-size:12px;font-weight:700;
letter-spacing:1px;text-transform:uppercase;
margin:32px 0 14px;
display:flex;align-items:center;gap:10px;
}
.plugin-subhead::before{
content:"::";color:var(--dim-2);font-weight:400;
}
.plugin-events{
background:var(--surface);
border:1px solid var(--border);
}
.plugin-events .key-row kbd{color:var(--cyan);min-width:150px}
.plugin-events .key-row:hover{background:var(--cyan-dim)}
.plugin-api-grid{
display:grid;
grid-template-columns:repeat(3,1fr);
gap:8px;
}
.plugin-api{
background:var(--surface);
border:1px solid var(--border);
padding:14px 16px;
transition:all .2s;
position:relative;
}
.plugin-api::before{
content:"";position:absolute;left:0;top:0;bottom:0;
width:2px;background:var(--dim-2);transition:background .2s;
}
.plugin-api:hover{background:var(--surface-2)}
.plugin-api:hover::before{background:var(--purple)}
.plugin-api-name{
color:var(--purple);font-size:12px;font-weight:700;
margin-bottom:5px;font-family:inherit;
letter-spacing:.3px;
}
.plugin-api-name::before{content:"cliamp.";color:var(--dim);font-weight:400}
.plugin-api p{font-size:10px;color:var(--dim-bright);line-height:1.55}
.plugin-api p code{color:var(--green);font-size:10px}
.community-plugin{
display:flex;align-items:center;gap:14px;
background:var(--surface);
border:1px solid var(--border);
padding:14px 18px;
transition:all .2s;
margin-bottom:6px;
position:relative;
}
.community-plugin::before{
content:"";position:absolute;left:0;top:0;bottom:0;
width:2px;background:var(--dim-2);transition:background .2s;
}
.community-plugin:hover::before{background:var(--green)}
.community-plugin:hover{background:var(--surface-2)}
.community-plugin-name{
color:var(--green);font-size:12px;font-weight:700;
font-family:inherit;
}
.community-plugin-name a{color:inherit;text-decoration:none}
.community-plugin-name a:hover{text-decoration:underline}
.community-plugin-desc{font-size:11px;color:var(--dim-bright);flex:1}
.community-plugin-author{font-size:10px;color:var(--dim)}
.community-plugin-author a{color:var(--dim-bright);text-decoration:none}
.community-plugin-author a:hover{color:var(--cyan)}
/* ═══════════ RADIO STATS ═══════════ */
.radio-header{
display:flex;align-items:center;justify-content:space-between;
margin-bottom:22px;flex-wrap:wrap;gap:16px;
}
.radio-live{
display:inline-flex;align-items:center;gap:8px;
font-size:10px;color:var(--green);letter-spacing:2px;
font-weight:800;
}
.radio-live-dot{
width:7px;height:7px;border-radius:50%;
background:var(--green);
box-shadow:0 0 8px var(--green-glow);
animation:pulse-dot 2s ease-in-out infinite;
}
@keyframes pulse-dot{
0%,100%{opacity:1}
50%{opacity:.35}
}
.radio-tabs{
display:flex;
border:1px solid var(--border-hi);
background:var(--bg-2);
width:fit-content;
}
.radio-tab{
background:transparent;border:none;
color:var(--dim-bright);font-size:10px;letter-spacing:1.5px;
text-transform:uppercase;padding:8px 18px;
cursor:pointer;transition:all .18s;
font-family:inherit;font-weight:700;
}
.radio-tab:hover{color:var(--fg-hi)}
.radio-tab.active{
background:var(--green-dim);color:var(--green);
text-shadow:0 0 6px var(--green-glow);
}
.radio-tab+.radio-tab{border-left:1px solid var(--border)}
.radio-stats-row{
display:grid;grid-template-columns:repeat(3,1fr);
gap:10px;margin-bottom:10px;
}
.radio-stat{
background:var(--surface);border:1px solid var(--border);
padding:22px 18px;text-align:left;
transition:border-color .2s;
position:relative;overflow:hidden;
}
.radio-stat:hover{border-color:var(--border-hi)}
.radio-stat::after{
content:"";position:absolute;right:-30px;top:-30px;
width:100px;height:100px;border-radius:50%;
background:var(--amber);opacity:.05;
pointer-events:none;
}
.radio-stat:first-child::after{background:var(--green);opacity:.06}
.radio-stat-value{
font-size:32px;color:var(--amber);
line-height:1;margin-bottom:10px;
font-weight:700;
letter-spacing:-1px;
display:flex;align-items:center;gap:10px;
}
.radio-stat:first-child .radio-stat-value{color:var(--green)}
.radio-stat-label{
font-size:9px;color:var(--dim-bright);
letter-spacing:2px;text-transform:uppercase;
font-weight:700;
}
.radio-stat-dot{
display:inline-block;width:8px;height:8px;border-radius:50%;
background:var(--green);box-shadow:0 0 6px var(--green-glow);
vertical-align:middle;
animation:pulse-dot 2s ease-in-out infinite;
flex-shrink:0;
}
.radio-card{
background:var(--surface);border:1px solid var(--border);
padding:20px;transition:border-color .2s;
}
.radio-card:hover{border-color:var(--border-hi)}
.radio-card+.radio-card{margin-top:10px}
.radio-card-title{
font-size:10px;color:var(--amber);
letter-spacing:2px;text-transform:uppercase;
margin-bottom:16px;font-weight:800;
display:flex;align-items:center;gap:8px;
}
.radio-card-title::before{
content:"▸";color:var(--dim);
}
.radio-chart{
height:120px;display:flex;
align-items:flex-end;gap:2px;
padding-top:16px;position:relative;
}
.radio-chart-bar{
flex:1;min-width:0;
position:relative;transition:height .6s ease,filter .2s;
cursor:default;
}
.radio-chart-bar:hover{filter:brightness(1.35)}
.radio-chart-bar .tooltip{
display:none;position:absolute;
bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);
background:var(--surface-3);border:1px solid var(--border-hi);
padding:5px 9px;font-size:10px;color:var(--fg-hi);
white-space:nowrap;z-index:10;pointer-events:none;
letter-spacing:.3px;
}
.radio-chart-bar:hover .tooltip{display:block}
.radio-chart-labels{
display:flex;justify-content:space-between;
font-size:9px;color:var(--dim-bright);
margin-top:8px;letter-spacing:.5px;
text-transform:uppercase;font-weight:600;
}
.radio-globe-wrap{
display:flex;align-items:center;gap:24px;flex-wrap:wrap;
}
.radio-globe-canvas{
width:300px;aspect-ratio:1;max-width:100%;
cursor:grab;opacity:0;transition:opacity .8s ease;
}
.radio-globe-canvas.loaded{opacity:1}
.radio-countries{
display:flex;flex-wrap:wrap;gap:6px;
flex:1;min-width:200px;align-content:flex-start;
}
.radio-country{
display:inline-flex;align-items:center;gap:6px;
background:var(--bg-2);border:1px solid var(--border);
padding:5px 9px;font-size:11px;color:var(--dim-bright);
transition:all .2s;
}
.radio-country:hover{border-color:var(--border-hi);color:var(--fg-hi)}
.radio-country-flag{font-size:13px;line-height:1}
.radio-country-sessions{
color:var(--amber);font-size:10px;margin-left:3px;opacity:.75;
font-weight:700;
}
.radio-error{
color:var(--dim);font-size:12px;
font-style:italic;padding:16px 0;
}
#radio-cmd-box{margin-top:20px}
/* ═══════════ FOOTER ═══════════ */
footer{
border-top:1px solid var(--border);
padding:40px 24px 50px;
position:relative;z-index:1;
background:var(--bg-2);
}
.footer-inner{
max-width:var(--wrap);margin:0 auto;
display:grid;grid-template-columns:1fr auto 1fr;
align-items:center;gap:24px;
}
.footer-brand{
color:var(--green);font-size:11px;font-weight:700;
letter-spacing:2.5px;
}
.footer-brand::before{content:"~ ";color:var(--dim)}
.footer-ascii{
color:var(--dim-2);font-size:10px;
text-align:center;
user-select:none;
letter-spacing:2px;
}
.footer-links{
display:flex;align-items:center;justify-content:flex-end;gap:18px;
}
.footer-link{
font-size:11px;color:var(--dim-bright);
display:inline-flex;align-items:center;gap:6px;
transition:color .2s;letter-spacing:.5px;
}
.footer-link:hover{color:var(--cyan)}
.footer-link svg{width:14px;height:14px;fill:currentColor}
/* ═══════════ ANIMATIONS ═══════════ */
@keyframes fade-up{
from{opacity:0;transform:translateY(10px)}
to{opacity:1;transform:translateY(0)}
}
/* ═══════════ RESPONSIVE ═══════════ */
@media(max-width:860px){
.features-grid{grid-template-columns:repeat(2,1fr)}
.sources-grid{grid-template-columns:repeat(3,1fr)}
.plugin-api-grid{grid-template-columns:repeat(2,1fr)}
.install-grid{grid-template-columns:1fr}
.cli-remote-grid{grid-template-columns:1fr}
.plugin-examples{grid-template-columns:1fr}
.plugin-examples .plugin-example:last-child{grid-column:auto}
}
@media(max-width:720px){
.hero{padding:36px 0 48px}
section{padding:48px 0}
.wrap{padding:0 16px}
.hero-logo{font-size:clamp(5px,1.5vw,11px)}
.features-grid,.sources-grid,.themes-grid,.keys-groups,.plugin-api-grid{grid-template-columns:1fr}
.themes-grid{grid-template-columns:repeat(2,1fr)}
.hero-cta{flex-direction:column;align-items:stretch;max-width:280px;margin:0 auto}
.btn{justify-content:center;padding:12px 24px}
.key-row{padding:6px 14px}
.key-row kbd{min-width:auto;margin-right:6px}
.nav-toggle{display:flex;align-items:center;justify-content:center}
.nav-links{
display:none;
position:absolute;top:52px;left:0;right:0;
flex-direction:column;gap:0;
background:rgba(5,5,7,.98);
backdrop-filter:blur(12px);
border-bottom:1px solid var(--border);
padding:4px 0;
}
.nav-links.open{display:flex}
.nav-links a{
padding:11px 24px;font-size:11px;
border-bottom:1px solid var(--border);
}
.nav-links a:last-child{border-bottom:none}
.nav-inner{position:relative}
.tty-body{padding:14px 14px 12px;font-size:12px;line-height:1.5}
.tty-head{padding:6px 12px;font-size:10px}
.tty-head-flag,.tty-head-pid{display:none}
.t-row{flex-wrap:wrap;white-space:normal}
.t-eqvol{gap:8px}
.t-vol-inline{margin-left:0;width:100%;max-width:none}
.t-help{font-size:11px;gap:10px}
.t-help kbd{font-size:10px}
.t-focus{margin-left:auto}
.t-eq-bands{font-size:11px}
.t-src-row{white-space:normal}
.t-seek{font-size:10px}
.t-bot{font-size:11px}
.rp-ch{font-size:12px}
.t-pill-static{font-size:12px}
.radio-stats-row{grid-template-columns:1fr}
.radio-stat-value{font-size:26px}
.radio-globe-wrap{justify-content:center}
.radio-globe-canvas{width:260px}
.radio-tab{padding:8px 12px;font-size:9px}
.sh{gap:10px}
.sh-name{font-size:12px}
.footer-inner{grid-template-columns:1fr;gap:16px;text-align:center}
.footer-links{justify-content:center}
.footer-brand{text-align:center}
.cli-remote-col .key-row kbd{min-width:auto}
.keys-group-title{font-size:9px}
}
@media(max-width:480px){
.hero-logo{font-size:clamp(4.5px,2.4vw,10px)}
.hero-tagline{letter-spacing:3px;font-size:10px}
.themes-grid{grid-template-columns:1fr}
.radio-globe-canvas{width:100%}
.wrap{padding:0 12px}
.tty-body{padding:12px 12px 10px;font-size:11px}
.t-title{letter-spacing:1.5px;font-size:13px}
.t-help{font-size:10px;gap:8px}
.install-box code{font-size:11px}
}
</style>
</head>
<body>
<!-- ═══ NAV ═══ -->
<nav class="nav">
<div class="nav-inner">
<a href="#" class="nav-brand"><span class="nav-brand-led"></span>CLIAMP</a>
<button class="nav-toggle" onclick="document.querySelector('.nav-links').classList.toggle('open');this.textContent=this.textContent==='≡' ? '×' : '≡'" aria-label="Menu"></button>
<div class="nav-links" onclick="this.classList.remove('open');document.querySelector('.nav-toggle').textContent='≡'">
<a href="#install">install</a>
<a href="#sources">sources</a>
<a href="#features">features</a>
<a href="#themes">themes</a>
<a href="#keys">keys</a>
<a href="#cli">cli</a>
<a href="#plugins">plugins</a>
<a href="#radio">radio</a>
<a href="https://github.com/bjarneo/cliamp">github</a>
</div>
</div>
</nav>
<!-- ═══ HERO ═══ -->
<section class="hero">
<div class="wrap">
<pre class="hero-logo"> ██████ ██ ██ █████ ███ ███ ██████
██ ██ ██ ██ ██ ████ ████ ██ ██
██ ██ ██ ███████ ██ ████ ██ ██████
██ ██ ██ ██ ██ ██ ██ ██ ██
██████ ███████ ██ ██ ██ ██ ██ ██ </pre>
<div class="hero-tagline">Terminal Music Player · <em>Winamp</em> for your shell</div>
<p class="hero-desc">
Playlists, EQ, visualizers, lyrics, remote control, and a Lua plugin system.
Streams from <em>Spotify</em>, Qobuz, YouTube Music, NetEase, Plex, Jellyfin, Emby, Navidrome, and 30,000+ radio stations.
</p>
<!-- Terminal (cliamp TUI simulation) -->
<div class="tty radio-player" id="radio-player">
<audio id="radio-audio" preload="none"></audio>
<div class="tty-head">
<span class="tty-head-prompt">$</span>
<span class="tty-head-cmd">cliamp</span>
<span class="tty-head-flag">--provider radio</span>
<span class="tty-head-spacer"></span>
<span class="tty-head-pid">tty1</span>
</div>
<div class="tty-body">
<div class="t-row">
<span class="t-title">C L I A M P</span>
<span class="t-focus">[Playlist]</span>
</div>
<div class="t-row t-track-row">
<span class="t-track-sym"></span>
<span class="t-track" id="t-track-name">Lofi Hip Hop Radio</span>
</div>
<div class="t-row t-time-row" onclick="toggleRadio()" role="button" tabindex="0" aria-label="Play / Stop" onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();toggleRadio()}">
<span class="t-time">00:00 / 00:00</span>
<span class="t-status">
<span class="t-status-stopped">■ Stopped <em>(click to play)</em></span>
<span class="t-status-loading">◌ Buffering…</span>
<span class="t-status-playing">● Streaming</span>
</span>
</div>
<div class="t-spacer"></div>
<pre id="ascii-viz"></pre>
<div class="t-seek">
<span class="t-seek-fill">━━━━━━━━━━━━━━━━━━━━</span>
<span class="t-seek-label">STREAMING</span>
<span class="t-seek-fill">━━━━━━━━━━━━━━━━━━━━</span>
</div>
<div class="t-spacer"></div>
<div class="t-row t-eqvol">
<span class="t-eq-inline">
<span class="t-label">EQ</span>
<span class="t-dim">[</span><span class="t-active">Rock</span><span class="t-dim">]</span>
<span class="t-eq-bands">+5 +4 +2 -1 -2 +2 +4 +5 +5 +5</span>
</span>
<span class="t-vol-inline">
<span class="t-label">VOL</span>
<input type="range" class="rp-vol" min="0" max="100" value="70" oninput="setRadioVol(this.value)" aria-label="Volume">
<span class="t-vol-db">+0dB</span>
</span>
</div>
<div class="t-row t-src-row">
<span class="t-label">SRC</span>
<button class="rp-ch active" data-station="lofi" onclick="switchChannel('lofi')">Lofi</button>
<button class="rp-ch" data-station="edm" onclick="switchChannel('edm')">EDM</button>
<button class="rp-ch" data-station="synthwave" onclick="switchChannel('synthwave')">Synthwave</button>
<span class="t-pill-static">[Local]</span>
<span class="t-pill-static">[Navidrome]</span>
<span class="t-pill-static">[Plex]</span>
<span class="t-pill-static">[Jellyfin]</span>
<span class="t-pill-static">[Emby]</span>
<span class="t-pill-static">[Spotify]</span>
<span class="t-pill-static">[YouTube]</span>
<span class="t-pill-static">[YT&nbsp;Music]</span>
</div>
<div class="t-spacer"></div>
<div class="t-row t-pl-header">
<span>▸─ Playlist ──</span>
<span class="t-active">[Shuffle]</span>
<span class="t-dim">[Repeat: Off]</span>
<span class="t-dim">[1/3]</span>
<span>──</span>
</div>
<div class="t-pl-item t-pl-active">▶ 1. Lofi Hip Hop Radio</div>
<div class="t-pl-item t-dim">&nbsp;&nbsp;2. EDM Pulse</div>
<div class="t-pl-item t-dim">&nbsp;&nbsp;3. Synthwave Nights</div>
<div class="t-spacer"></div>
<div class="t-row t-help">
<span class="t-help-item"><kbd>↑↓</kbd> Scroll</span>
<span class="t-help-item"><kbd>Enter</kbd> Play</span>
<span class="t-help-item"><kbd>Spc</kbd> ▶❚❚</span>
<span class="t-help-item"><kbd>*</kbd> Fav</span>
<span class="t-help-item"><kbd>Tab</kbd> Focus</span>
<span class="t-help-item"><kbd>?</kbd> Keys</span>
</div>
<div class="t-row t-bot">
<span>
<span class="t-label">SPD</span>
<span class="t-dim">[</span><span class="t-fg">1x</span><span class="t-dim">]</span>
</span>
<span class="t-bot-right">↓ 0.7 MB&nbsp;&nbsp;32 KB/s</span>
</div>
</div>
</div>
<div class="tty-hint">
<code id="radio-hint-cmd"><span class="p">$ </span>cliamp https://radio.cliamp.stream/lofi/stream.pls</code>
</div>
<div class="hero-cta">
<a href="#install" class="btn btn-primary">Install<span class="btn-arrow"></span></a>
<a href="https://github.com/bjarneo/cliamp" class="btn btn-ghost">Source<span class="btn-arrow"></span></a>
</div>
</div>
</section>
<!-- ═══ MARQUEE (providers band) ═══ -->
<div class="marquee" aria-hidden="true">
<div class="marquee-track">
<span class="marquee-item"><strong>MP3</strong></span>
<span class="marquee-item"><strong>FLAC</strong></span>
<span class="marquee-item"><strong>OGG</strong></span>
<span class="marquee-item"><strong>OPUS</strong></span>
<span class="marquee-item"><strong>WAV</strong></span>
<span class="marquee-item"><strong>AAC</strong></span>
<span class="marquee-item"><strong>ALAC</strong></span>
<span class="marquee-item"><strong>Spotify</strong></span>
<span class="marquee-item"><strong>Qobuz</strong></span>
<span class="marquee-item"><strong>YouTube</strong></span>
<span class="marquee-item"><strong>Plex</strong></span>
<span class="marquee-item"><strong>Jellyfin</strong></span>
<span class="marquee-item"><strong>Emby</strong></span>
<span class="marquee-item"><strong>Navidrome</strong></span>
<span class="marquee-item"><strong>SoundCloud</strong></span>
<span class="marquee-item"><strong>NetEase</strong></span>
<span class="marquee-item"><strong>Bandcamp</strong></span>
<span class="marquee-item"><strong>Bilibili</strong></span>
<span class="marquee-item"><strong>RSS / Podcasts</strong></span>
<span class="marquee-item"><strong>30k+ Radio Stations</strong></span>
<span class="marquee-item"><strong>ICY metadata</strong></span>
<span class="marquee-item"><strong>MPRIS</strong></span>
<span class="marquee-item"><strong>Lua plugins</strong></span>
<!-- duplicate for seamless loop -->
<span class="marquee-item"><strong>MP3</strong></span>
<span class="marquee-item"><strong>FLAC</strong></span>
<span class="marquee-item"><strong>OGG</strong></span>
<span class="marquee-item"><strong>OPUS</strong></span>
<span class="marquee-item"><strong>WAV</strong></span>
<span class="marquee-item"><strong>AAC</strong></span>
<span class="marquee-item"><strong>ALAC</strong></span>
<span class="marquee-item"><strong>Spotify</strong></span>
<span class="marquee-item"><strong>Qobuz</strong></span>
<span class="marquee-item"><strong>YouTube</strong></span>
<span class="marquee-item"><strong>Plex</strong></span>
<span class="marquee-item"><strong>Jellyfin</strong></span>
<span class="marquee-item"><strong>Emby</strong></span>
<span class="marquee-item"><strong>Navidrome</strong></span>
<span class="marquee-item"><strong>SoundCloud</strong></span>
<span class="marquee-item"><strong>NetEase</strong></span>
<span class="marquee-item"><strong>Bandcamp</strong></span>
<span class="marquee-item"><strong>Bilibili</strong></span>
<span class="marquee-item"><strong>RSS / Podcasts</strong></span>
<span class="marquee-item"><strong>30k+ Radio Stations</strong></span>
<span class="marquee-item"><strong>ICY metadata</strong></span>
<span class="marquee-item"><strong>MPRIS</strong></span>
<span class="marquee-item"><strong>Lua plugins</strong></span>
</div>
</div>
<!-- ═══ DEMO ═══ -->
<section>
<div class="wrap">
<div class="sh">
<span class="sh-num">01</span>
<span class="sh-name">demo.mp4</span>
<span class="sh-line"></span>
</div>
<div class="demo-wrap">
<video id="demo-vid" class="demo-video" autoplay loop muted playsinline>
<source src="https://github.com/user-attachments/assets/fbc33d20-e3ac-4a62-a991-8a2f0243c8ea" type="video/mp4">
</video>
<button class="sound-toggle" onclick="toggleSound(this)" aria-label="Toggle sound">
<svg class="icon-muted" viewBox="0 0 24 24"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>
<svg class="icon-unmuted" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
</button>
</div>
</div>
</section>
<!-- ═══ INSTALL ═══ -->
<section id="install">
<div class="wrap">
<div class="sh">
<span class="sh-num">02</span>
<span class="sh-name">install.sh</span>
<span class="sh-line"></span>
</div>
<div class="install-grid">
<div class="install-box" onclick="copyCmd(this,'curl -fsSL https://raw.githubusercontent.com/bjarneo/cliamp/HEAD/install.sh | sh')">
<div class="install-platform">Direct</div>
<code><span class="prompt">$ </span>curl -fsSL …/install.sh | sh</code>
<span class="copied-msg">COPIED</span>
</div>
<div class="install-box" onclick="copyCmd(this,'brew install bjarneo/cliamp/cliamp')">
<div class="install-platform">Homebrew</div>
<code><span class="prompt">$ </span>brew install bjarneo/cliamp/cliamp</code>
<span class="copied-msg">COPIED</span>
</div>
<div class="install-box" onclick="copyCmd(this,'yay -S cliamp')">
<div class="install-platform">Arch (AUR)</div>
<code><span class="prompt">$ </span>yay -S cliamp</code>
<span class="copied-msg">COPIED</span>
</div>
<div class="install-box" onclick="copyCmd(this,'go install github.com/bjarneo/cliamp@latest')">
<div class="install-platform">Go</div>
<code><span class="prompt">$ </span>go install github.com/bjarneo/cliamp@latest</code>
<span class="copied-msg">COPIED</span>
</div>
</div>
<div class="install-note">
Or build from source — <a href="https://github.com/bjarneo/cliamp">see the README</a>. Windows builds are available from Releases; when <code>HOME</code> is unset, config lives under <code>%APPDATA%\cliamp</code>.
</div>
<div class="next-step">
<div class="next-step-label">Next step · configure providers</div>
<h3>Run the setup wizard</h3>
<p>An interactive TUI walks you through Navidrome, Plex, Jellyfin, Emby, Spotify, Qobuz, NetEase, and YouTube Music. It links to each provider's credential page, validates the connection, and writes the right block to your config file.</p>
<div class="install-box" onclick="copyCmd(this,'cliamp setup')">
<div class="install-platform">Setup</div>
<code><span class="prompt">$ </span>cliamp setup</code>
<span class="copied-msg">COPIED</span>
</div>
</div>
</div>
</section>
<!-- ═══ SOURCES / PROVIDERS ═══ -->
<section id="sources">
<div class="wrap">
<div class="sh">
<span class="sh-num">03</span>
<span class="sh-name">sources</span>
<span class="sh-line"></span>
</div>
<p class="sources-intro">
Stream from everywhere. Every provider runs through the same <em>playlist, EQ, visualizer, and lyrics pipeline</em> — your config follows you across services. Run <code>cliamp setup</code> for an interactive wizard that walks you through Navidrome, Plex, Jellyfin, Emby, Spotify, Qobuz, NetEase, and YouTube Music.
</p>
<div class="sources-grid">
<div class="source" style="--src-color:#1db954">
<div class="source-badge">OAuth · cached</div>
<div class="source-name">Spotify</div>
<div class="source-desc">Stream your Premium library. Bring your own developer <code>client_id</code> for a private quota, or use the built-in shared one. Search &amp; add tracks with <kbd>F</kbd>. Currently unavailable on Windows builds.</div>
</div>
<div class="source" style="--src-color:#0a0a0a">
<div class="source-badge">OAuth · lossless</div>
<div class="source-name">Qobuz</div>
<div class="source-desc">Opt-in: set <code>[qobuz] enabled = true</code>. Stream lossless FLAC up to 24-bit/192kHz from your subscription. Browse favorites &amp; playlists, search with <kbd>Ctrl+F</kbd>. Sign in once via OAuth in your browser.</div>
</div>
<div class="source" style="--src-color:#ff0000">
<div class="source-badge">yt-dlp</div>
<div class="source-name">YouTube</div>
<div class="source-desc">Search videos with <kbd>Ctrl+F</kbd>, then play, append, or queue from a results list.</div>
</div>
<div class="source" style="--src-color:#ff0000">
<div class="source-badge">OAuth · cached</div>
<div class="source-name">YT Music</div>
<div class="source-desc">Browse your playlists with auto music/non-music classification. Set <code>cookies_from</code> to play through your browser session and avoid YouTube bot blocks (keep <code>yt-dlp</code> current).</div>
</div>
<div class="source" style="--src-color:#e5a00d">
<div class="source-badge">Media server</div>
<div class="source-name">Plex</div>
<div class="source-desc">Browse albums and stream from your Plex Media Server.</div>
</div>
<div class="source" style="--src-color:#00a4dc">
<div class="source-badge">Media server</div>
<div class="source-name">Jellyfin</div>
<div class="source-desc">Artists, albums, tracks — buffered gapless playback.</div>
</div>
<div class="source" style="--src-color:#52b54b">
<div class="source-badge">Media server</div>
<div class="source-name">Emby</div>
<div class="source-desc">Browse your Emby music library with API key or username/password auth.</div>
</div>
<div class="source" style="--src-color:#2ecc71">
<div class="source-badge">Subsonic API</div>
<div class="source-name">Navidrome</div>
<div class="source-desc">Browse your self-hosted library with synced lyrics.</div>
</div>
<div class="source" style="--src-color:#ff7700">
<div class="source-badge">Provider · yt-dlp</div>
<div class="source-name">SoundCloud</div>
<div class="source-desc">Opt-in: set <code>[soundcloud] enabled = true</code>. Search with <kbd>Ctrl+F</kbd>, browse curated genre playlists, or add <code>user</code> for your profile. <code>cookies_from</code> unlocks Go+ tracks via your browser session.</div>
</div>
<div class="source" style="--src-color:#d33a31">
<div class="source-badge">Provider · yt-dlp</div>
<div class="source-name">NetEase</div>
<div class="source-desc">Opt-in: run <code>cliamp setup</code> after signing in at <code>music.163.com</code>, then choose your browser. Browse liked songs, account playlists, saved playlists, and charts with browser-cookie playback.</div>
</div>
<div class="source" style="--src-color:#629aa9">
<div class="source-badge">yt-dlp</div>
<div class="source-name">Bandcamp</div>
<div class="source-desc">Paste any Bandcamp URL and it just plays.</div>
</div>
<div class="source" style="--src-color:#00a1d6">
<div class="source-badge">yt-dlp</div>
<div class="source-name">Bilibili</div>
<div class="source-desc">Audio tracks from Bilibili videos via yt-dlp.</div>
</div>
<div class="source" style="--src-color:#f7a000">
<div class="source-badge">30k+ stations</div>
<div class="source-name">Radio</div>
<div class="source-desc">Browse stations by country, tag, or name. ICY metadata.</div>
</div>
<div class="source" style="--src-color:#c3a6ff">
<div class="source-badge">RSS feeds</div>
<div class="source-name">Podcasts</div>
<div class="source-desc">Pass any podcast RSS URL and listen in your terminal.</div>
</div>
<div class="source" style="--src-color:#00ff66">
<div class="source-badge">Local</div>
<div class="source-name">Files</div>
<div class="source-desc">MP3, FLAC, OGG, Opus, WAV, AAC, ALAC, WMA. ID3 tags read.</div>
</div>
</div>
<div class="source-config" id="netease-config">
<div class="next-step-label">NetEase Cloud Music</div>
<h3>Browser-session setup</h3>
<p>Sign in at <code>music.163.com</code>, then run <code>cliamp setup</code>. Choose NetEase Cloud Music, pick the browser you used, and the wizard validates the session before writing config.</p>
<pre><code>[netease]
enabled = true
cookies_from = "chrome"
user_id = "your-account-user-id"</code></pre>
<p>After setup, press <kbd>M</kbd> to open NetEase and <kbd>Ctrl+F</kbd> to search. Playback uses <code>yt-dlp</code> with the same browser cookie source.</p>
</div>
</div>
</section>
<!-- ═══ FEATURES ═══ -->
<section id="features">
<div class="wrap">
<div class="sh">
<span class="sh-num">04</span>
<span class="sh-name">features</span>
<span class="sh-line"></span>
</div>
<div class="features-grid">
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">10-Band Equalizer</div>
<p>Parametric EQ with presets: Rock, Jazz, Pop, Classical, and more.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Themes &amp; Visualizers</div>
<p>17 built-in themes and multiple visualizer modes. Hot-swap with <kbd>t</kbd> / <kbd>v</kbd>.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Playlists</div>
<p>TOML playlists, M3U/M3U8/PLS import/export, duplicate-safe writes, and TUI/CLI sort tools.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Recently Played</div>
<p>Auto-recorded listening history. Browse it as a virtual playlist or run <code>cliamp history</code> from the shell.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">HTTP Streaming</div>
<p>Play from URLs, internet radio, remote M3U playlists, and HLS (<code>.m3u8</code>) live streams via ffmpeg.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Synced Lyrics</div>
<p>Embedded local lyrics first, then LRCLIB/NetEase fallback. Auto-scrolling for timestamped lyrics.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Gapless Playback</div>
<p>Seamless transitions with preloaded next-track buffering.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">MPRIS / Media Keys</div>
<p>Desktop integration. Hardware media keys, <code>playerctl</code>, and embedded local cover art.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Quickshell Widget (Omarchy)</div>
<p>Drop-in <a href="https://quickshell.org">Quickshell</a> now-playing card with a Winamp 2-style spectrum, tracking your active Omarchy theme. <a href="https://github.com/bjarneo/cliamp/blob/main/docs/quickshell.md">Setup</a>.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Audio Quality</div>
<p>Configurable sample rate (22kHz192kHz), buffer size, resample.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Live Radio Metadata</div>
<p>ICY/Shoutcast metadata for the current song on internet radio, plus API-based now-playing for FIP and NTS.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Embedded Tag Reading</div>
<p>ID3v2, Vorbis comments, MP4 atoms - artist, album, genre, year, lyrics, cover art.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">CLI Flags</div>
<p>Override volume, shuffle, repeat, theme, EQ, sample rate per-session.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Diagnostic Logging</div>
<p>File logs at <span class="path">~/.config/cliamp/cliamp.log</span>. Set <code>log_level</code> in config or <code>--log-level</code> on the CLI: <code>debug</code>, <code>info</code>, <code>warn</code>, <code>error</code>.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Self-Update</div>
<p>Run <code>--upgrade</code> to update to the latest release in-terminal.</p>
</div>
<div class="feature">
<div class="feature-icon">$</div>
<div class="feature-name">Env-Interpolated Secrets</div>
<p>Reference any string in <code>config.toml</code> from the environment with <code>${VAR}</code> or <code>$VAR</code>. Keep passwords and tokens out of the file.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Remote Control</div>
<p>Control a running instance from another terminal via local-socket IPC. Run with <code>--daemon</code> for headless playback driven entirely by scripts or Waybar.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Lua Plugins</div>
<p>Lua 5.1 sandboxed plugin system. Hook events, add visualizers, push data.</p>
</div>
<div class="feature">
<div class="feature-icon">💾</div>
<div class="feature-name">Save to Disk</div>
<p>Press <kbd>Ctrl+S</kbd> to save the current track to <code>~/Music</code>.</p>
</div>
</div>
</div>
</section>
<!-- ═══ THEMES ═══ -->
<section id="themes">
<div class="wrap">
<div class="sh">
<span class="sh-num">05</span>
<span class="sh-name">themes.toml</span>
<span class="sh-line"></span>
</div>
<p class="themes-intro">
17 built-in themes. Press <kbd>t</kbd> to switch, or use <code>--theme "name"</code> from the CLI.
Drop a <code>.toml</code> file in <span class="path">~/.config/cliamp/themes/</span> to add your own.
</p>
<div class="themes-grid">
<div class="theme-card">
<div class="theme-card-name">Ayu Mirage Dark</div>
<div class="theme-bars">
<span style="color:#73d0ff">██</span>
<span style="color:#f3f4f5">██</span>
<span style="color:#cccac2">██</span>
<span style="color:#d5ff80">██</span>
<span style="color:#ffad66">██</span>
<span style="color:#f28779">██</span>
</div>
</div>
<div class="theme-card">
<div class="theme-card-name">Catppuccin</div>
<div class="theme-bars">
<span style="color:#89b4fa">██</span>
<span style="color:#cdd6f4">██</span>
<span style="color:#9399b2">██</span>
<span style="color:#a6e3a1">██</span>
<span style="color:#f9e2af">██</span>
<span style="color:#f38ba8">██</span>
</div>
</div>
<div class="theme-card">
<div class="theme-card-name">Catppuccin Latte</div>
<div class="theme-bars">
<span style="color:#1e66f5">██</span>
<span style="color:#4c4f69">██</span>
<span style="color:#8c8fa1">██</span>
<span style="color:#40a02b">██</span>
<span style="color:#df8e1d">██</span>
<span style="color:#d20f39">██</span>
</div>
</div>
<div class="theme-card">
<div class="theme-card-name">Ethereal</div>
<div class="theme-bars">
<span style="color:#7d82d9">██</span>
<span style="color:#ffcead">██</span>
<span style="color:#9a96a8">██</span>
<span style="color:#92a593">██</span>
<span style="color:#E9BB4F">██</span>
<span style="color:#ED5B5A">██</span>
</div>
</div>
<div class="theme-card">
<div class="theme-card-name">Everforest</div>
<div class="theme-bars">
<span style="color:#7fbbb3">██</span>
<span style="color:#d3c6aa">██</span>
<span style="color:#7a8478">██</span>
<span style="color:#a7c080">██</span>
<span style="color:#dbbc7f">██</span>
<span style="color:#e67e80">██</span>
</div>
</div>
<div class="theme-card">
<div class="theme-card-name">Flexoki Light</div>
<div class="theme-bars">
<span style="color:#205EA6">██</span>
<span style="color:#100F0F">██</span>
<span style="color:#6F6E69">██</span>
<span style="color:#879A39">██</span>
<span style="color:#D0A215">██</span>
<span style="color:#D14D41">██</span>
</div>
</div>
<div class="theme-card">
<div class="theme-card-name">Gruvbox</div>
<div class="theme-bars">
<span style="color:#7daea3">██</span>
<span style="color:#d4be98">██</span>
<span style="color:#a89984">██</span>
<span style="color:#a9b665">██</span>
<span style="color:#d8a657">██</span>
<span style="color:#ea6962">██</span>
</div>
</div>
<div class="theme-card">
<div class="theme-card-name">Hackerman</div>
<div class="theme-bars">
<span style="color:#82FB9C">██</span>
<span style="color:#ddf7ff">██</span>
<span style="color:#8e95b8">██</span>
<span style="color:#4fe88f">██</span>
<span style="color:#50f7d4">██</span>
<span style="color:#50f872">██</span>
</div>
</div>
<div class="theme-card">
<div class="theme-card-name">Kanagawa</div>
<div class="theme-bars">
<span style="color:#7e9cd8">██</span>
<span style="color:#dcd7ba">██</span>
<span style="color:#938aa9">██</span>
<span style="color:#76946a">██</span>
<span style="color:#c0a36e">██</span>
<span style="color:#c34043">██</span>
</div>
</div>
<div class="theme-card">
<div class="theme-card-name">Matte Black</div>
<div class="theme-bars">
<span style="color:#e68e0d">██</span>
<span style="color:#bebebe">██</span>
<span style="color:#777777">██</span>
<span style="color:#FFC107">██</span>
<span style="color:#b91c1c">██</span>
<span style="color:#D35F5F">██</span>
</div>
</div>
<div class="theme-card">
<div class="theme-card-name">Miasma</div>
<div class="theme-bars">
<span style="color:#78824b">██</span>
<span style="color:#c2c2b0">██</span>
<span style="color:#666666">██</span>
<span style="color:#5f875f">██</span>
<span style="color:#b36d43">██</span>
<span style="color:#685742">██</span>
</div>
</div>
<div class="theme-card">
<div class="theme-card-name">Nord</div>
<div class="theme-bars">
<span style="color:#81a1c1">██</span>
<span style="color:#d8dee9">██</span>
<span style="color:#8690a0">██</span>
<span style="color:#a3be8c">██</span>
<span style="color:#ebcb8b">██</span>
<span style="color:#bf616a">██</span>
</div>
</div>
<div class="theme-card">
<div class="theme-card-name">Osaka Jade</div>
<div class="theme-bars">
<span style="color:#509475">██</span>
<span style="color:#F7E8B2">██</span>
<span style="color:#C1C497">██</span>
<span style="color:#549e6a">██</span>
<span style="color:#459451">██</span>
<span style="color:#FF5345">██</span>
</div>
</div>
<div class="theme-card">
<div class="theme-card-name">Ristretto</div>
<div class="theme-bars">
<span style="color:#f38d70">██</span>
<span style="color:#e6d9db">██</span>
<span style="color:#948a8b">██</span>
<span style="color:#adda78">██</span>
<span style="color:#f9cc6c">██</span>
<span style="color:#fd6883">██</span>
</div>
</div>
<div class="theme-card">
<div class="theme-card-name">Ros&eacute; Pine</div>
<div class="theme-bars">
<span style="color:#56949f">██</span>
<span style="color:#575279">██</span>
<span style="color:#908caa">██</span>
<span style="color:#286983">██</span>
<span style="color:#ea9d34">██</span>
<span style="color:#b4637a">██</span>
</div>
</div>
<div class="theme-card">
<div class="theme-card-name">Tokyo Night</div>
<div class="theme-bars">
<span style="color:#7aa2f7">██</span>
<span style="color:#cfc9c2">██</span>
<span style="color:#737aa2">██</span>
<span style="color:#9ece6a">██</span>
<span style="color:#e0af68">██</span>
<span style="color:#f7768e">██</span>
</div>
</div>
<div class="theme-card">
<div class="theme-card-name">Vantablack</div>
<div class="theme-bars">
<span style="color:#8d8d8d">██</span>
<span style="color:#ffffff">██</span>
<span style="color:#8d8d8d">██</span>
<span style="color:#b6b6b6">██</span>
<span style="color:#cecece">██</span>
<span style="color:#a4a4a4">██</span>
</div>
</div>
</div>
<div class="themes-note">
Each swatch: accent · bright fg · fg · green · yellow · red
</div>
</div>
</section>
<!-- ═══ KEYBINDINGS ═══ -->
<section id="keys">
<div class="wrap">
<div class="sh">
<span class="sh-num">06</span>
<span class="sh-name">keybindings.conf</span>
<span class="sh-line"></span>
</div>
<div class="keys-groups">
<div class="keys-group">
<div class="keys-group-title">Transport</div>
<div class="keys-group-body">
<div class="key-row"><kbd>Space</kbd><span>Play / Pause</span></div>
<div class="key-row"><kbd>s</kbd><span>Stop</span></div>
<div class="key-row"><kbd>&gt; / &lt;</kbd><span>Next / Previous track</span></div>
<div class="key-row"><kbd>&larr; / &rarr;</kbd><span>Seek &minus;/+5s</span></div>
<div class="key-row"><kbd>Shift+&larr; / Shift+&rarr;</kbd><span>Seek &minus;/+30s (default)</span></div>
<div class="key-row"><kbd>N then j</kbd><span>Seek to N&times;10% (e.g. <kbd>7j</kbd> = 70%)</span></div>
<div class="key-row"><kbd>Ctrl+J</kbd><span>Jump to time</span></div>
<div class="key-row"><kbd>+ / &minus;</kbd><span>Volume up / down</span></div>
<div class="key-row"><kbd>] / [</kbd><span>Speed &plusmn;0.25x</span></div>
<div class="key-row"><kbd>m</kbd><span>Toggle mono</span></div>
<div class="key-row"><kbd>r / z</kbd><span>Repeat / Shuffle</span></div>
</div>
</div>
<div class="keys-group">
<div class="keys-group-title">Navigation</div>
<div class="keys-group-body">
<div class="key-row"><kbd>Tab</kbd><span>Focus: Playlist / EQ</span></div>
<div class="key-row"><kbd>j / k</kbd><span>Scroll or adjust EQ (wraps)</span></div>
<div class="key-row"><kbd>h / l</kbd><span>EQ band left / right</span></div>
<div class="key-row"><kbd>PgUp / PgDn</kbd><span>Page up / down</span></div>
<div class="key-row"><kbd>Ctrl+U / Ctrl+D</kbd><span>Page up / down</span></div>
<div class="key-row"><kbd>Home / End</kbd><span>Jump to top / end</span></div>
<div class="key-row"><kbd>g / G</kbd><span>Vim-style top / bottom</span></div>
<div class="key-row"><kbd>Enter</kbd><span>Play selected</span></div>
<div class="key-row"><kbd>Shift+&uarr; / Shift+&darr;</kbd><span>Move track up / down</span></div>
<div class="key-row"><kbd>Esc / b</kbd><span>Back to provider</span></div>
</div>
</div>
<div class="keys-group">
<div class="keys-group-title">Search &amp; Browse</div>
<div class="keys-group-body">
<div class="key-row"><kbd>/</kbd><span>Search playlist (fuzzy; ↑ / ↓ or Ctrl+N / Ctrl+P move results; Ctrl+U / Ctrl+D page results)</span></div>
<div class="key-row"><kbd>Ctrl+F</kbd><span>Search active provider (Spotify, Qobuz, Navidrome, Jellyfin, Emby, Plex, NetEase, Local) or YouTube fallback; Local search is fuzzy</span></div>
<div class="key-row"><kbd>f</kbd><span>Toggle bookmark &#9733; / radio favorite</span></div>
<div class="key-row"><kbd>u</kbd><span>Load URL (stream / playlist)</span></div>
<div class="key-row"><kbd>o</kbd><span>Open file browser</span></div>
<div class="key-row"><kbd>a / A</kbd><span>Queue (play next) / Queue manager</span></div>
<div class="key-row"><kbd>x</kbd><span>Remove highlighted track from current playlist</span></div>
<div class="key-row"><kbd>w</kbd><span>Write highlighted track to a local playlist</span></div>
<div class="key-row"><kbd>p</kbd><span>Playlist manager</span></div>
<div class="key-row"><kbd>L</kbd><span>Browse local playlists</span></div>
<div class="key-row"><kbd>Ctrl+X</kbd><span>Expand playlist</span></div>
</div>
</div>
<div class="keys-group">
<div class="keys-group-title">Inside Playlist Manager</div>
<div class="keys-group-body">
<div class="key-row"><kbd>/</kbd><span>Filter playlists or tracks (incremental)</span></div>
<div class="key-row"><kbd>Enter</kbd><span>Open playlist · play <em>highlighted</em> track on the tracks screen</span></div>
<div class="key-row"><kbd>p</kbd><span>Play all tracks from the top</span></div>
<div class="key-row"><kbd>a</kbd><span>List: add now-playing · Tracks: mark/unmark all visible</span></div>
<div class="key-row"><kbd>Space</kbd><span>Mark/unmark track and advance</span></div>
<div class="key-row"><kbd>[ / ]</kbd><span>Move track and save the playlist</span></div>
<div class="key-row"><kbd>s</kbd><span>Sort tracks by track/title/artist/album/path</span></div>
<div class="key-row"><kbd>o</kbd><span>Add files to the open playlist</span></div>
<div class="key-row"><kbd>w</kbd><span>Write marked/highlighted tracks to another playlist</span></div>
<div class="key-row"><kbd>r</kbd><span>Rename the playlist</span></div>
<div class="key-row"><kbd>d</kbd><span>Delete playlist (confirms) / remove marked tracks</span></div>
<div class="key-row"><kbd>u</kbd><span>Undo last manager edit</span></div>
<div class="key-row"><kbd>Esc</kbd><span>Close or go back</span></div>
</div>
</div>
<div class="keys-group">
<div class="keys-group-title">File Browser</div>
<div class="keys-group-body">
<div class="key-row"><kbd>↑ / ↓&nbsp;&nbsp;&nbsp;j / k</kbd><span>Move cursor</span></div>
<div class="key-row"><kbd>← / →&nbsp;&nbsp;&nbsp;h / l</kbd><span>Back / open directory or file</span></div>
<div class="key-row"><kbd>Space</kbd><span>Select file or directory</span></div>
<div class="key-row"><kbd>a</kbd><span>Select all visible audio files</span></div>
<div class="key-row"><kbd>R</kbd><span>Replace the current queue with selected files</span></div>
<div class="key-row"><kbd>w</kbd><span>Write selected files to a local playlist</span></div>
<div class="key-row"><kbd>/</kbd><span>Filter files</span></div>
<div class="key-row"><kbd>Esc / o</kbd><span>Close browser</span></div>
</div>
</div>
<div class="keys-group">
<div class="keys-group-title">Provider Playlists Pane</div>
<div class="keys-group-body">
<div class="key-row"><kbd>↑ / ↓&nbsp;&nbsp;&nbsp;j / k</kbd><span>Move cursor (wraps)</span></div>
<div class="key-row"><kbd>Ctrl+U / Ctrl+D</kbd><span>Scroll by page</span></div>
<div class="key-row"><kbd>Enter</kbd><span>Load the highlighted playlist's tracks into the queue</span></div>
<div class="key-row"><kbd>/</kbd><span>Filter the visible playlists</span></div>
<div class="key-row"><kbd>Ctrl+F</kbd><span>Online / server search via the provider's own search</span></div>
<div class="key-row"><kbd>Ctrl+R</kbd><span>Refresh — re-pull playlists from the provider</span></div>
<div class="key-row"><kbd>S / N / P / J / E / Y / C / M / Q / L / R</kbd><span>Switch to Spotify / Navidrome / Plex / Jellyfin / Emby / YouTube / SoundCloud / NetEase / Qobuz / Local / Radio</span></div>
<div class="key-row"><kbd></kbd><span>Marker on the row whose tracks are currently loaded</span></div>
</div>
</div>
<div class="keys-group">
<div class="keys-group-title">Provider Browser (<kbd>N</kbd>)</div>
<div class="keys-group-body">
<div class="key-row"><kbd>↑ / ↓&nbsp;&nbsp;&nbsp;j / k</kbd><span>Move cursor (wraps top↔bottom)</span></div>
<div class="key-row"><kbd>← / →&nbsp;&nbsp;&nbsp;h / l</kbd><span>Back / drill in</span></div>
<div class="key-row"><kbd>Enter</kbd><span>Open (artists/albums) · play the highlighted track and queue the rest</span></div>
<div class="key-row"><kbd>R</kbd><span>Replace queue with all visible tracks (start from #1)</span></div>
<div class="key-row"><kbd>a</kbd><span>Append all visible tracks to the queue</span></div>
<div class="key-row"><kbd>q</kbd><span>Queue the highlighted track to play next</span></div>
<div class="key-row"><kbd>s</kbd><span>Cycle album sort (album list only)</span></div>
<div class="key-row"><kbd>/</kbd><span>Filter the visible list (search bar appears under the title)</span></div>
<div class="key-row"><kbd>S / N / P / J / E / Y / C / M / Q / L / R</kbd><span>Quick-switch to another provider without going back to the main pane</span></div>
<div class="key-row"><kbd>Esc</kbd><span>Walk back one level / close the browser</span></div>
</div>
</div>
<div class="keys-group">
<div class="keys-group-title">Search Results</div>
<div class="keys-group-body">
<div class="key-row"><kbd>&uarr; / &darr;&nbsp;&nbsp;&nbsp;j / k&nbsp;&nbsp;&nbsp;Ctrl+N / Ctrl+P</kbd><span>Move cursor (single item, wraps)</span></div>
<div class="key-row"><kbd>Ctrl+U / Ctrl+D</kbd><span>Scroll results by page</span></div>
<div class="key-row"><kbd>Enter</kbd><span>Play selected track now</span></div>
<div class="key-row"><kbd>a</kbd><span>Append selected to playlist</span></div>
<div class="key-row"><kbd>q</kbd><span>Queue selected to play next</span></div>
<div class="key-row"><kbd>p</kbd><span>Save to Spotify playlist (Spotify only)</span></div>
<div class="key-row"><kbd>Esc</kbd><span>Back to search input</span></div>
</div>
</div>
<div class="keys-group">
<div class="keys-group-title">Providers &amp; UI</div>
<div class="keys-group-body">
<div class="key-row"><kbd>N</kbd><span>Navidrome</span></div>
<div class="key-row"><kbd>S</kbd><span>Spotify</span></div>
<div class="key-row"><kbd>P</kbd><span>Plex</span></div>
<div class="key-row"><kbd>J</kbd><span>Jellyfin</span></div>
<div class="key-row"><kbd>E</kbd><span>Emby</span></div>
<div class="key-row"><kbd>Y</kbd><span>YouTube</span></div>
<div class="key-row"><kbd>Q</kbd><span>Qobuz</span></div>
<div class="key-row"><kbd>R</kbd><span>Radio</span></div>
<div class="key-row"><kbd>e / t / v</kbd><span>EQ preset / Theme / Visualizer</span></div>
<div class="key-row"><kbd>Ctrl+V</kbd><span>Pick visualizer (live preview list)</span></div>
<div class="key-row"><kbd>V</kbd><span>Full-screen visualizer</span></div>
<div class="key-row"><kbd>y / i</kbd><span>Lyrics / Track info</span></div>
<div class="key-row"><kbd>Ctrl+H</kbd><span>Toggle album headers</span></div>
<div class="key-row"><kbd>Ctrl+S</kbd><span>Save to ~/Music</span></div>
<div class="key-row"><kbd>? / Ctrl+K</kbd><span>Show keymap</span></div>
<div class="key-row"><kbd>q</kbd><span>Quit</span></div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══ CLI + REMOTE ═══ -->
<section id="cli">
<div class="wrap">
<div class="sh">
<span class="sh-num">07</span>
<span class="sh-name">cli &amp; remote control</span>
<span class="sh-line"></span>
</div>
<p class="cli-remote-intro">
CLI flags override any config option for a single session.
Remote commands control a running instance over local-socket IPC — open another terminal and talk to <code>cliamp</code> directly.
</p>
<div class="cli-remote-grid">
<div class="cli-remote-col">
<div class="cli-remote-title">Flags</div>
<div class="keys-group-body">
<div class="key-row"><kbd>--volume &lt;dB&gt;</kbd><span>[volume_min, +6]</span></div>
<div class="key-row"><kbd>--shuffle</kbd><span>Enable shuffle</span></div>
<div class="key-row"><kbd>--repeat &lt;mode&gt;</kbd><span>off, all, one</span></div>
<div class="key-row"><kbd>--mono / --no-mono</kbd><span>Mono downmix</span></div>
<div class="key-row"><kbd>--auto-play</kbd><span>Start immediately</span></div>
<div class="key-row"><kbd>--theme &lt;name&gt;</kbd><span>UI theme</span></div>
<div class="key-row"><kbd>--eq-preset &lt;name&gt;</kbd><span>EQ preset</span></div>
<div class="key-row"><kbd>--visualizer &lt;mode&gt;</kbd><span>Bars, Wave, Matrix, Retro…</span></div>
<div class="key-row"><kbd>--sample-rate &lt;Hz&gt;</kbd><span>Output sample rate</span></div>
<div class="key-row"><kbd>--buffer-ms &lt;ms&gt;</kbd><span>Speaker buffer 50500</span></div>
<div class="key-row"><kbd>--resample-quality &lt;n&gt;</kbd><span>14</span></div>
<div class="key-row"><kbd>--bit-depth &lt;n&gt;</kbd><span>PCM: 16 or 32</span></div>
<div class="key-row"><kbd>--compact</kbd><span>Cap width at 80 cols</span></div>
<div class="key-row"><kbd>--low-power</kbd><span>Lower UI cadence and disable visualization</span></div>
<div class="key-row"><kbd>--daemon / -d</kbd><span>Run headless (IPC only, no TUI)</span></div>
<div class="key-row"><kbd>--playlist &lt;name&gt;</kbd><span>Load TOML playlist</span></div>
<div class="key-row"><kbd>--provider &lt;name&gt;</kbd><span>Default provider</span></div>
<div class="key-row"><kbd>--upgrade</kbd><span>Update in place</span></div>
<div class="key-row"><kbd>--help / --version</kbd><span>Help &amp; version</span></div>
</div>
</div>
<div class="cli-remote-col">
<div class="cli-remote-title">Remote</div>
<div class="keys-group-body">
<div class="key-row"><kbd>play / pause / toggle</kbd><span>Playback control</span></div>
<div class="key-row"><kbd>stop</kbd><span>Stop playback</span></div>
<div class="key-row"><kbd>next / prev</kbd><span>Track navigation</span></div>
<div class="key-row"><kbd>status [--json]</kbd><span>Current state</span></div>
<div class="key-row"><kbd>volume &lt;dB&gt;</kbd><span>Set volume</span></div>
<div class="key-row"><kbd>seek &lt;secs&gt;</kbd><span>Relative seek</span></div>
<div class="key-row"><kbd>load "Name"</kbd><span>Load a playlist</span></div>
<div class="key-row"><kbd>queue /path/file</kbd><span>Queue a track</span></div>
<div class="key-row"><kbd>history [--limit N]</kbd><span>Show recently played</span></div>
<div class="key-row"><kbd>history clear</kbd><span>Wipe recently-played file</span></div>
<div class="key-row"><kbd>shuffle [on|off]</kbd><span>Toggle or set shuffle</span></div>
<div class="key-row"><kbd>repeat [off|all|one]</kbd><span>Set or cycle repeat</span></div>
<div class="key-row"><kbd>mono [on|off]</kbd><span>Mono output</span></div>
<div class="key-row"><kbd>speed &lt;ratio&gt;</kbd><span>0.252.0</span></div>
<div class="key-row"><kbd>eq &lt;preset&gt;</kbd><span>Flat, Rock, Pop, Jazz…</span></div>
<div class="key-row"><kbd>eq --band &lt;0-9&gt; &lt;dB&gt;</kbd><span>Set individual band</span></div>
<div class="key-row"><kbd>device list</kbd><span>List output devices</span></div>
<div class="key-row"><kbd>device &lt;name&gt;</kbd><span>Switch device</span></div>
<div class="key-row"><kbd>theme &lt;name|list&gt;</kbd><span>Set or list themes</span></div>
<div class="key-row"><kbd>vis &lt;name|next|list&gt;</kbd><span>Set or cycle visualizer</span></div>
</div>
</div>
<div class="cli-remote-col">
<div class="cli-remote-title">Playlist CLI</div>
<div class="keys-group-body">
<div class="key-row"><kbd>playlist list</kbd><span>List local playlists</span></div>
<div class="key-row"><kbd>playlist create "Name"</kbd><span>Create an empty playlist</span></div>
<div class="key-row"><kbd>playlist add "Name" file</kbd><span>Append tracks, skipping duplicates</span></div>
<div class="key-row"><kbd>playlist rename "Old" "New"</kbd><span>Rename a playlist</span></div>
<div class="key-row"><kbd>playlist dedupe "Name"</kbd><span>Remove duplicate paths</span></div>
<div class="key-row"><kbd>playlist sort "Name" --by album</kbd><span>Sort in place</span></div>
<div class="key-row"><kbd>playlist doctor [Name]</kbd><span>Report missing local files</span></div>
<div class="key-row"><kbd>playlist export "Name" -o mix.m3u</kbd><span>Export M3U or PLS</span></div>
<div class="key-row"><kbd>playlist import mix.pls</kbd><span>Import M3U/M3U8/PLS</span></div>
<div class="key-row"><kbd>playlist bookmark "Name" --index N</kbd><span>Toggle bookmark flag</span></div>
<div class="key-row"><kbd>playlist bookmarks</kbd><span>List bookmarked tracks</span></div>
<div class="key-row"><kbd>playlist enrich "Name"</kbd><span>Probe duration and album metadata</span></div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══ PLUGINS ═══ -->
<section id="plugins">
<div class="wrap">
<div class="sh">
<span class="sh-num">08</span>
<span class="sh-name">plugins.lua</span>
<span class="sh-line"></span>
</div>
<p class="plugins-intro">
Lua 5.1 plugin system. Hook into playback events, add custom visualizers, or push data to external services.
Each plugin runs in an isolated sandbox — a crash in one cannot affect others or the player.
Install or copy a <code>.lua</code> file into <span class="path">~/.config/cliamp/plugins/</span>, then approve its exact SHA-256 with <code>cliamp plugins trust &lt;name&gt;</code>. Changed and untrusted plugins do not run.
</p>
<div class="plugin-examples">
<div class="plugin-example">
<div class="plugin-example-title">now-playing.lua</div>
<pre><span class="cm">-- ~/.config/cliamp/plugins/now-playing.lua</span>
<span class="kw">local</span> p = plugin.<span class="fn">register</span>({
name = <span class="str">"now-playing"</span>,
type = <span class="str">"hook"</span>,
})
p:<span class="fn">on</span>(<span class="str">"track.change"</span>, <span class="kw">function</span>(track)
cliamp.fs.<span class="fn">write</span>(<span class="str">"/tmp/cliamp-now-playing"</span>,
track.artist <span class="op">..</span> <span class="str">" - "</span> <span class="op">..</span> track.title)
<span class="kw">end</span>)
p:<span class="fn">on</span>(<span class="str">"app.quit"</span>, <span class="kw">function</span>()
cliamp.fs.<span class="fn">remove</span>(<span class="str">"/tmp/cliamp-now-playing"</span>)
<span class="kw">end</span>)</pre>
</div>
<div class="plugin-example">
<div class="plugin-example-title">webhook.lua</div>
<pre><span class="cm">-- ~/.config/cliamp/plugins/webhook.lua</span>
<span class="kw">local</span> p = plugin.<span class="fn">register</span>({
name = <span class="str">"webhook"</span>,
type = <span class="str">"hook"</span>,
})
<span class="kw">local</span> url = p:<span class="fn">config</span>(<span class="str">"url"</span>)
p:<span class="fn">on</span>(<span class="str">"track.change"</span>, <span class="kw">function</span>(track)
<span class="kw">if not</span> url <span class="kw">then return end</span>
cliamp.http.<span class="fn">post</span>(url, {
json = { title = track.title, artist = track.artist }
})
<span class="kw">end</span>)</pre>
</div>
<div class="plugin-example">
<div class="plugin-example-title">simple-bars.lua (custom visualizer)</div>
<pre><span class="cm">-- ~/.config/cliamp/plugins/simple-bars.lua</span>
<span class="kw">local</span> p = plugin.<span class="fn">register</span>({
name = <span class="str">"simple-bars"</span>,
type = <span class="str">"visualizer"</span>,
})
<span class="cm">-- Called ~20 FPS. bands: 10 values (0.0-1.0)</span>
<span class="kw">function</span> p:<span class="fn">render</span>(bands, frame)
<span class="kw">local</span> lines = {}
<span class="kw">for</span> row = 5, 1, -1 <span class="kw">do</span>
<span class="kw">local</span> line = <span class="str">""</span>
<span class="kw">for</span> i = 1, 10 <span class="kw">do</span>
<span class="kw">if</span> bands[i] <span class="op">></span> (row - 1) / 5 <span class="kw">then</span>
line = line <span class="op">..</span> <span class="str">"██████ "</span>
<span class="kw">else</span>
line = line <span class="op">..</span> <span class="str">" "</span>
<span class="kw">end</span>
<span class="kw">end</span>
table.<span class="fn">insert</span>(lines, line)
<span class="kw">end</span>
<span class="kw">return</span> table.<span class="fn">concat</span>(lines, <span class="str">"\n"</span>)
<span class="kw">end</span></pre>
</div>
</div>
<div class="plugin-subhead">Events</div>
<div class="plugin-events">
<div class="keys-group-body">
<div class="key-row"><kbd>track.change</kbd><span>New track starts playing</span></div>
<div class="key-row"><kbd>track.scrobble</kbd><span>Track played &ge;50% or &ge;4 min</span></div>
<div class="key-row"><kbd>playback.state</kbd><span>Play, pause, stop, seek, volume change</span></div>
<div class="key-row"><kbd>player.seek</kbd><span>A seek completes (position, duration)</span></div>
<div class="key-row"><kbd>player.volume</kbd><span>Volume changes (db)</span></div>
<div class="key-row"><kbd>player.eq</kbd><span>EQ band or preset changes</span></div>
<div class="key-row"><kbd>player.mode</kbd><span>Shuffle toggled or repeat cycled</span></div>
<div class="key-row"><kbd>queue.change</kbd><span>Playlist or play-next queue changes</span></div>
<div class="key-row"><kbd>app.start</kbd><span>After all plugins loaded</span></div>
<div class="key-row"><kbd>app.quit</kbd><span>Before shutdown</span></div>
</div>
</div>
<div class="plugin-subhead">API</div>
<div class="plugin-api-grid">
<div class="plugin-api">
<div class="plugin-api-name">player</div>
<p>Read-only state: <code>state()</code>, <code>position()</code>, <code>volume()</code>, <code>shuffle()</code>, <code>eq_bands()</code></p>
</div>
<div class="plugin-api">
<div class="plugin-api-name">track</div>
<p>Current metadata: <code>title()</code>, <code>artist()</code>, <code>album()</code>, <code>genre()</code>, <code>duration_secs()</code></p>
</div>
<div class="plugin-api">
<div class="plugin-api-name">queue</div>
<p>Read the playlist: <code>list()</code>, <code>count()</code>, <code>current()</code>. Mutate it with <code>add()</code>, <code>jump()</code>, <code>remove()</code>, <code>move()</code> (requires <code>permissions = {"control"}</code>).</p>
</div>
<div class="plugin-api">
<div class="plugin-api-name">store</div>
<p>Persistent per-plugin KV: <code>set()</code>, <code>get()</code>, <code>delete()</code>, <code>keys()</code>, <code>clear()</code>. Survives restarts; scoped to your plugin.</p>
</div>
<div class="plugin-api">
<div class="plugin-api-name">http</div>
<p>HTTP client: <code>get()</code>, <code>post()</code>. JSON, form body, headers, 5s timeout.</p>
</div>
<div class="plugin-api">
<div class="plugin-api-name">fs</div>
<p>Sandboxed I/O: <code>write()</code>, <code>read()</code>, <code>append()</code>, <code>remove()</code>, <code>exists()</code>, <code>mkdir()</code>, <code>listdir()</code>. Writes are restricted to the system temp directory (<code>/tmp/</code> on Unix), <code>~/.config/cliamp/</code>, <code>~/.local/share/cliamp/</code>, and <code>~/Music/cliamp/</code> (on Windows, if <code>HOME</code> is unset, the config directory resolves to <code>%APPDATA%\cliamp</code>).</p>
</div>
<div class="plugin-api">
<div class="plugin-api-name">exec</div>
<p>Spawn from allowlist (<code>yt-dlp</code>, <code>ffmpeg</code> by default): <code>run(bin, args, opts)</code>, streams stdout/stderr, cancelable. Requires <code>permissions = {"exec"}</code>.</p>
</div>
<div class="plugin-api">
<div class="plugin-api-name">json</div>
<p><code>encode()</code> and <code>decode()</code> for JSON serialization.</p>
</div>
<div class="plugin-api">
<div class="plugin-api-name">crypto</div>
<p><code>md5()</code>, <code>sha256()</code>, <code>hmac_sha256()</code> for hashing and signing.</p>
</div>
<div class="plugin-api">
<div class="plugin-api-name">notify</div>
<p>Desktop notifications via <code>notify-send</code>. Works with mako, dunst, etc.</p>
</div>
<div class="plugin-api">
<div class="plugin-api-name">message</div>
<p><code>message(text, secs)</code> → transient message in the player's status bar.</p>
</div>
<div class="plugin-api">
<div class="plugin-api-name">timer</div>
<p>Schedule callbacks: <code>after()</code>, <code>every()</code>, <code>cancel()</code></p>
</div>
<div class="plugin-api">
<div class="plugin-api-name">log</div>
<p><code>info()</code>, <code>warn()</code>, <code>error()</code>, <code>debug()</code><span class="path">plugins.log</span></p>
</div>
<div class="plugin-api">
<div class="plugin-api-name">config</div>
<p>Read per-plugin values from <code>[plugins.name]</code> in config.toml via <code>p:config(key)</code></p>
</div>
<div class="plugin-api">
<div class="plugin-api-name">bind</div>
<p><code>p:bind(key, desc, fn)</code> — register a TUI keybinding. With a description it appears in the Ctrl+K overlay. Rejects keys reserved by cliamp. Requires <code>permissions = {"keymap"}</code>.</p>
</div>
<div class="plugin-api">
<div class="plugin-api-name">command</div>
<p><code>p:command(name, fn)</code> — register a shell-invokable command. Run with <code>cliamp plugins call &lt;plugin&gt; &lt;cmd&gt; [args]</code>.</p>
</div>
</div>
<div class="plugin-subhead">Community</div>
<div class="community-plugin">
<div class="community-plugin-name"><a href="https://github.com/tetsuo76/cliamp-lastfm">cliamp-lastfm</a></div>
<div class="community-plugin-desc">Last.fm scrobbling</div>
<div class="community-plugin-author"><a href="https://github.com/tetsuo76">@tetsuo76</a></div>
</div>
<div class="community-plugin">
<div class="community-plugin-name"><a href="https://github.com/AlexZeitler/cliamp-plugin-led-burst">cliamp-plugin-led-burst</a></div>
<div class="community-plugin-desc">Stereo LED matrix visualizer</div>
<div class="community-plugin-author"><a href="https://github.com/AlexZeitler">@AlexZeitler</a></div>
</div>
<div class="community-plugin">
<div class="community-plugin-name"><a href="https://github.com/HANCORE-linux/cliamp-plugin-nightrider">cliamp-plugin-nightrider</a></div>
<div class="community-plugin-desc">Nightrider visualizer</div>
<div class="community-plugin-author"><a href="https://github.com/HANCORE-linux">@HANCORE-linux</a></div>
</div>
<div class="community-plugin">
<div class="community-plugin-name"><a href="https://github.com/8bit64k/cliamp-plugin-tubeamp">cliamp-plugin-tubeamp</a></div>
<div class="community-plugin-desc">Vacuum-tube amplifier visualizer</div>
<div class="community-plugin-author"><a href="https://github.com/8bit64k">@8bit64k</a></div>
</div>
<div class="community-plugin">
<div class="community-plugin-name"><a href="https://github.com/8bit64k/cliamp-plugin-nova">cliamp-plugin-nova</a></div>
<div class="community-plugin-desc">Concentric-ring spectrum visualizer</div>
<div class="community-plugin-author"><a href="https://github.com/8bit64k">@8bit64k</a></div>
</div>
</section>
<!-- ═══ RADIO STATS ═══ -->
<section id="radio">
<div class="wrap">
<div class="sh">
<span class="sh-num">09</span>
<span class="sh-name">radio.stats</span>
<span class="sh-line"></span>
</div>
<div class="radio-header">
<div class="radio-tabs" id="radio-stat-tabs">
<button class="radio-tab active" data-station="total" onclick="switchStatTab('total')">Total</button>
<button class="radio-tab" data-station="edm" onclick="switchStatTab('edm')">EDM</button>
<button class="radio-tab" data-station="lofi" onclick="switchStatTab('lofi')">Lofi</button>
<button class="radio-tab" data-station="synthwave" onclick="switchStatTab('synthwave')">Synthwave</button>
</div>
<div class="radio-live"><span class="radio-live-dot"></span> LIVE</div>
</div>
<div id="radio-error"></div>
<div class="radio-stats-row">
<div class="radio-stat">
<div class="radio-stat-value"><span class="radio-stat-dot"></span><span id="radio-now">-</span></div>
<div class="radio-stat-label">listening now</div>
</div>
<div class="radio-stat">
<div class="radio-stat-value" id="radio-sessions">-</div>
<div class="radio-stat-label">total sessions</div>
</div>
<div class="radio-stat">
<div class="radio-stat-value" id="radio-hours">-</div>
<div class="radio-stat-label">listen hours</div>
</div>
</div>
<div class="radio-card">
<div class="radio-card-title">Daily Activity</div>
<div class="radio-chart" id="radio-chart"></div>
<div class="radio-chart-labels" id="radio-chart-labels"></div>
</div>
<div class="radio-card">
<div class="radio-card-title">Listeners Around the World</div>
<div class="radio-globe-wrap">
<canvas class="radio-globe-canvas" id="radio-globe"></canvas>
<div class="radio-countries" id="radio-countries"></div>
</div>
</div>
<div class="radio-card">
<div class="radio-card-title">Top Countries</div>
<div class="radio-countries" id="radio-total-countries"></div>
</div>
<div class="install-box" id="radio-cmd-box" onclick="copyCmd(this,'cliamp https://radio.cliamp.stream/lofi/stream.pls')" style="display:none">
<span class="copied-msg">COPIED</span>
<div class="install-platform">Tune in</div>
<code><span class="prompt">$ </span><span id="radio-cmd-text">cliamp https://radio.cliamp.stream/lofi/stream.pls</span></code>
</div>
</div>
</section>
<!-- ═══ FOOTER ═══ -->
<footer>
<div class="footer-inner">
<div class="footer-brand">CLIAMP · built for the terminal</div>
<div class="footer-ascii">░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░</div>
<div class="footer-links">
<a href="https://github.com/bjarneo/cliamp" class="footer-link">
<svg viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
GitHub
</a>
<a href="https://x.com/iamdothash" class="footer-link">
<svg viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
@iamdothash
</a>
</div>
</div>
</footer>
<script>
/* ── Copy to clipboard ── */
function copyCmd(el,cmd){
navigator.clipboard.writeText(cmd).then(function(){
var m=el.querySelector('.copied-msg');
m.classList.add('show');
setTimeout(function(){m.classList.remove('show')},1500);
});
}
function toggleSound(btn){
var vid=document.getElementById('demo-vid');
vid.muted=!vid.muted;
btn.classList.toggle('unmuted',!vid.muted);
}
/* ── ASCII Visualizer ── */
(function(){
var viz=document.getElementById('ascii-viz');
if(!viz)return;
var cols=36;
var rows=5;
var heights=[];
var targets=[];
var speeds=[];
for(var i=0;i<cols;i++){
heights.push(Math.random()*rows);
targets.push(Math.random()*rows);
speeds.push(0.03+Math.random()*0.06);
}
function tick(){
for(var i=0;i<cols;i++){
heights[i]+=(targets[i]-heights[i])*speeds[i];
if(Math.abs(heights[i]-targets[i])<0.15){
targets[i]=Math.random()*rows;
}
}
var lines=[];
for(var r=rows;r>=1;r--){
var line='';
for(var c=0;c<cols;c++){
var h=heights[c];
if(h>=r) line+='\u2588';
else if(h>=r-0.25) line+='\u2593';
else if(h>=r-0.5) line+='\u2592';
else if(h>=r-0.75) line+='\u2591';
else line+=' ';
}
lines.push(line);
}
viz.textContent=lines.join('\n');
requestAnimationFrame(tick);
}
tick();
})();
/* ── Radio Player ── */
var currentChannel='lofi';
var radioStreams={edm:'https://radio.cliamp.stream/edm/stream',lofi:'https://radio.cliamp.stream/lofi/stream',synthwave:'https://radio.cliamp.stream/synthwave/stream'};
var radioPls={edm:'https://radio.cliamp.stream/edm/stream.pls',lofi:'https://radio.cliamp.stream/lofi/stream.pls',synthwave:'https://radio.cliamp.stream/synthwave/stream.pls'};
var radioNames={edm:'EDM Pulse',lofi:'Lofi Hip Hop Radio',synthwave:'Synthwave Nights'};
function switchChannel(ch){
currentChannel=ch;
document.querySelectorAll('.rp-ch').forEach(function(b){b.classList.toggle('active',b.dataset.station===ch)});
var hintEl=document.getElementById('radio-hint-cmd');
hintEl.textContent='';
var prompt=document.createElement('span');
prompt.className='p';
prompt.textContent='$ ';
hintEl.appendChild(prompt);
hintEl.appendChild(document.createTextNode('cliamp '+radioPls[ch]));
var nameEl=document.getElementById('t-track-name');
if(nameEl)nameEl.textContent=radioNames[ch]||ch;
var a=document.getElementById('radio-audio');
var p=document.getElementById('radio-player');
if(!a.paused){
a.pause();
a.src=radioStreams[ch];
a.play().catch(function(){p.classList.remove('loading','playing')});
}
}
function toggleRadio(){
var a=document.getElementById('radio-audio');
var p=document.getElementById('radio-player');
if(!a.paused){
a.pause();
a.removeAttribute('src');
a.load();
p.classList.remove('playing','loading');
return;
}
p.classList.add('loading');
p.classList.remove('playing');
a.src=radioStreams[currentChannel];
a.volume=document.querySelector('.rp-vol').value/100;
a.play().then(function(){
p.classList.remove('loading');
p.classList.add('playing');
}).catch(function(){
p.classList.remove('loading');
});
}
function setRadioVol(v){
document.getElementById('radio-audio').volume=v/100;
}
/* ── Radio Statistics ── */
var currentStatTab='total';
var radioStatsData=null;
function switchStatTab(ch){
currentStatTab=ch;
document.querySelectorAll('.radio-tab').forEach(function(b){b.classList.toggle('active',b.dataset.station===ch)});
var box=document.getElementById('radio-cmd-box');
if(ch==='total'){
box.style.display='none';
}else{
var cmd='cliamp '+radioPls[ch];
box.style.display='';
box.setAttribute('onclick',"copyCmd(this,'"+cmd+"')");
document.getElementById('radio-cmd-text').textContent=cmd;
}
if(radioStatsData)renderStationStats(radioStatsData,ch);
}
var countryFlags={
AF:"\uD83C\uDDE6\uD83C\uDDEB",AL:"\uD83C\uDDE6\uD83C\uDDF1",DZ:"\uD83C\uDDE9\uD83C\uDDFF",AR:"\uD83C\uDDE6\uD83C\uDDF7",AU:"\uD83C\uDDE6\uD83C\uDDFA",AT:"\uD83C\uDDE6\uD83C\uDDF9",BE:"\uD83C\uDDE7\uD83C\uDDEA",BR:"\uD83C\uDDE7\uD83C\uDDF7",
BG:"\uD83C\uDDE7\uD83C\uDDEC",CA:"\uD83C\uDDE8\uD83C\uDDE6",CL:"\uD83C\uDDE8\uD83C\uDDF1",CN:"\uD83C\uDDE8\uD83C\uDDF3",CO:"\uD83C\uDDE8\uD83C\uDDF4",HR:"\uD83C\uDDED\uD83C\uDDF7",CZ:"\uD83C\uDDE8\uD83C\uDDFF",DK:"\uD83C\uDDE9\uD83C\uDDF0",
EG:"\uD83C\uDDEA\uD83C\uDDEC",EE:"\uD83C\uDDEA\uD83C\uDDEA",FI:"\uD83C\uDDEB\uD83C\uDDEE",FR:"\uD83C\uDDEB\uD83C\uDDF7",DE:"\uD83C\uDDE9\uD83C\uDDEA",GR:"\uD83C\uDDEC\uD83C\uDDF7",HK:"\uD83C\uDDED\uD83C\uDDF0",HU:"\uD83C\uDDED\uD83C\uDDFA",
IN:"\uD83C\uDDEE\uD83C\uDDF3",ID:"\uD83C\uDDEE\uD83C\uDDE9",IE:"\uD83C\uDDEE\uD83C\uDDEA",IL:"\uD83C\uDDEE\uD83C\uDDF1",IT:"\uD83C\uDDEE\uD83C\uDDF9",JP:"\uD83C\uDDEF\uD83C\uDDF5",KR:"\uD83C\uDDF0\uD83C\uDDF7",LV:"\uD83C\uDDF1\uD83C\uDDFB",
LT:"\uD83C\uDDF1\uD83C\uDDF9",MY:"\uD83C\uDDF2\uD83C\uDDFE",MX:"\uD83C\uDDF2\uD83C\uDDFD",NL:"\uD83C\uDDF3\uD83C\uDDF1",NZ:"\uD83C\uDDF3\uD83C\uDDFF",NO:"\uD83C\uDDF3\uD83C\uDDF4",PK:"\uD83C\uDDF5\uD83C\uDDF0",PE:"\uD83C\uDDF5\uD83C\uDDEA",
PH:"\uD83C\uDDF5\uD83C\uDDED",PL:"\uD83C\uDDF5\uD83C\uDDF1",PT:"\uD83C\uDDF5\uD83C\uDDF9",RO:"\uD83C\uDDF7\uD83C\uDDF4",RU:"\uD83C\uDDF7\uD83C\uDDFA",SA:"\uD83C\uDDF8\uD83C\uDDE6",RS:"\uD83C\uDDF7\uD83C\uDDF8",SG:"\uD83C\uDDF8\uD83C\uDDEC",
SK:"\uD83C\uDDF8\uD83C\uDDF0",SI:"\uD83C\uDDF8\uD83C\uDDEE",ZA:"\uD83C\uDDFF\uD83C\uDDE6",ES:"\uD83C\uDDEA\uD83C\uDDF8",SE:"\uD83C\uDDF8\uD83C\uDDEA",CH:"\uD83C\uDDE8\uD83C\uDDED",TW:"\uD83C\uDDF9\uD83C\uDDFC",TH:"\uD83C\uDDF9\uD83C\uDDED",
TR:"\uD83C\uDDF9\uD83C\uDDF7",UA:"\uD83C\uDDFA\uD83C\uDDE6",AE:"\uD83C\uDDE6\uD83C\uDDEA",GB:"\uD83C\uDDEC\uD83C\uDDE7",US:"\uD83C\uDDFA\uD83C\uDDF8",VN:"\uD83C\uDDFB\uD83C\uDDF3",BD:"\uD83C\uDDE7\uD83C\uDDE9",NG:"\uD83C\uDDF3\uD83C\uDDEC"
};
function formatDate(iso){
var parts=iso.split("-");
var months=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
return months[parseInt(parts[1],10)-1]+" "+parseInt(parts[2],10);
}
function renderStationStats(data,stationKey){
var station;
if(stationKey==='total'){
station={total_sessions:0,total_listen_hours:0,active_listeners:0,top_countries:[],active_listener_countries:[],daily:[]};
var allStations=data.stations||{};
var dailyMap={};
var activeCountryMap={};
var totalCountryMap={};
Object.keys(allStations).forEach(function(k){
var s=allStations[k];
station.total_sessions+=s.total_sessions||0;
station.total_listen_hours+=s.total_listen_hours||0;
station.active_listeners+=s.active_listeners||0;
(s.daily||[]).forEach(function(d){
if(!dailyMap[d.date])dailyMap[d.date]={date:d.date,sessions:0,listen_hours:0};
dailyMap[d.date].sessions+=d.sessions;
dailyMap[d.date].listen_hours+=d.listen_hours;
});
(s.active_listener_countries||[]).forEach(function(c){
if(!activeCountryMap[c.country_code])activeCountryMap[c.country_code]={country:c.country,country_code:c.country_code,sessions:0};
activeCountryMap[c.country_code].sessions+=c.sessions;
});
(s.top_countries||[]).forEach(function(c){
if(!totalCountryMap[c.country_code])totalCountryMap[c.country_code]={country:c.country,country_code:c.country_code,sessions:0};
totalCountryMap[c.country_code].sessions+=c.sessions;
});
});
station.daily=Object.keys(dailyMap).sort().map(function(k){return dailyMap[k]});
station.active_listener_countries=Object.keys(activeCountryMap).map(function(k){return activeCountryMap[k]}).sort(function(a,b){return b.sessions-a.sessions});
station.top_countries=Object.keys(totalCountryMap).map(function(k){return totalCountryMap[k]}).sort(function(a,b){return b.sessions-a.sessions});
}else{
station=data.stations&&data.stations[stationKey];
if(!station) station={total_sessions:0,total_listen_hours:0,active_listeners:0,top_countries:[],active_listener_countries:[],daily:[]};
}
document.getElementById("radio-now").textContent=station.active_listeners;
document.getElementById("radio-sessions").textContent=station.total_sessions.toLocaleString();
document.getElementById("radio-hours").textContent=station.total_listen_hours.toFixed(1);
var daily=station.daily||[];
var chart=document.getElementById("radio-chart");
var labels=document.getElementById("radio-chart-labels");
chart.textContent="";
labels.textContent="";
var barColors={total:"var(--fg)",edm:"var(--cyan)",lofi:"var(--amber)",synthwave:"#ff66cc"};
if(daily.length>0){
var maxHours=Math.max.apply(null,daily.map(function(d){return d.listen_hours}))||1;
daily.forEach(function(d){
var pct=Math.max((d.listen_hours/maxHours)*100,3);
var bar=document.createElement("div");
bar.className="radio-chart-bar";
bar.style.height=pct+"%";
bar.style.background=barColors[stationKey]||barColors.lofi;
bar.style.opacity="0.75";
var tip=document.createElement("div");
tip.className="tooltip";
tip.textContent=d.date+" · "+d.listen_hours.toFixed(1)+"h, "+d.sessions+" sessions";
bar.appendChild(tip);
chart.appendChild(bar);
});
var lStart=document.createElement("span");
lStart.textContent=formatDate(daily[0].date);
var lEnd=document.createElement("span");
lEnd.textContent=formatDate(daily[daily.length-1].date);
labels.appendChild(lStart);
labels.appendChild(lEnd);
}
var activeCountries=station.active_listener_countries||[];
var cEl=document.getElementById("radio-countries");
cEl.textContent="";
activeCountries.forEach(function(c){
var flag=countryFlags[c.country_code]||"\uD83C\uDF10";
var div=document.createElement("div");
div.className="radio-country";
var flagSpan=document.createElement("span");
flagSpan.className="radio-country-flag";
flagSpan.textContent=flag;
var sessSpan=document.createElement("span");
sessSpan.className="radio-country-sessions";
sessSpan.textContent=c.sessions;
div.appendChild(flagSpan);
div.appendChild(document.createTextNode(c.country));
div.appendChild(sessSpan);
cEl.appendChild(div);
});
var totalCountries=station.top_countries||[];
var tEl=document.getElementById("radio-total-countries");
tEl.textContent="";
totalCountries.forEach(function(c){
var flag=countryFlags[c.country_code]||"\uD83C\uDF10";
var div=document.createElement("div");
div.className="radio-country";
var flagSpan=document.createElement("span");
flagSpan.className="radio-country-flag";
flagSpan.textContent=flag;
var sessSpan=document.createElement("span");
sessSpan.className="radio-country-sessions";
sessSpan.textContent=c.sessions;
div.appendChild(flagSpan);
div.appendChild(document.createTextNode(c.country));
div.appendChild(sessSpan);
tEl.appendChild(div);
});
if(!window._globeInitialized){
window._radioCountries=activeCountries;
initGlobe(activeCountries);
window._globeInitialized=true;
}
}
function renderRadioStats(){
fetch("https://radio.cliamp.stream/statistics")
.then(function(r){return r.json()})
.then(function(data){
radioStatsData=data;
document.getElementById("radio-error").textContent="";
renderStationStats(data,currentStatTab);
})
.catch(function(){
var el=document.getElementById("radio-error");
el.textContent="";
var err=document.createElement("span");
err.className="radio-error";
err.textContent="Could not load radio stats";
el.appendChild(err);
});
}
/* ── Country coords for globe ── */
var countryCoords={
US:[37.09,-95.71],BR:[-14.24,-51.93],GB:[55.38,-3.44],DE:[51.17,10.45],
NO:[60.47,8.47],FR:[46.23,2.21],CZ:[49.82,15.47],MX:[23.63,-102.55],
HU:[47.16,19.50],SE:[60.13,18.64],CA:[56.13,-106.35],AU:[-25.27,133.78],
IN:[20.59,78.96],JP:[36.20,138.25],KR:[35.91,127.77],NL:[52.13,5.29],
IT:[41.87,12.57],ES:[40.46,-3.75],PL:[51.92,19.15],PT:[39.40,-8.22],
CH:[46.82,8.23],AT:[47.52,14.55],BE:[50.50,4.47],FI:[61.92,25.75],
DK:[56.26,9.50],IE:[53.14,-7.69],RO:[45.94,24.97],BG:[42.73,25.49],
HR:[45.10,15.20],SK:[48.67,19.70],SI:[46.15,14.99],EE:[58.60,25.01],
LV:[56.88,24.60],LT:[55.17,23.88],GR:[39.07,21.82],TR:[38.96,35.24],
UA:[48.38,31.17],RU:[61.52,105.32],ZA:[-30.56,22.94],IL:[31.05,34.85],
AE:[23.42,53.85],SA:[23.89,45.08],EG:[26.82,30.80],NG:[9.08,8.68],
KE:[-0.02,37.91],AR:[-38.42,-63.62],CL:[-35.68,-71.54],CO:[4.57,-74.30],
PE:[-9.19,-75.02],PH:[12.88,121.77],TH:[15.87,100.99],VN:[14.06,108.28],
MY:[4.21,101.98],SG:[1.35,103.82],ID:[-0.79,113.92],TW:[23.70,120.96],
HK:[22.40,114.11],NZ:[-40.90,174.89],PK:[30.38,69.35],BD:[23.68,90.36]
};
/* ── Canvas globe with land outlines ── */
var _globeAnim=null;
var _globeResizeTimer=null;
var _landPolygons=null;
function topoToPolygons(topo,objectName){
var obj=topo.objects[objectName];
var arcs=topo.arcs;
var tf=topo.transform;
var polys=[];
function decodeArc(arcIdx){
var reverse=arcIdx<0;
var idx=reverse?~arcIdx:arcIdx;
var arc=arcs[idx];
var coords=[];
var x=0,y=0;
for(var i=0;i<arc.length;i++){
x+=arc[i][0];y+=arc[i][1];
coords.push([tf.scale[0]*x+tf.translate[0],tf.scale[1]*y+tf.translate[1]]);
}
if(reverse)coords.reverse();
return coords;
}
function ringFromArcs(arcRefs){
var pts=[];
for(var i=0;i<arcRefs.length;i++){
var c=decodeArc(arcRefs[i]);
for(var j=(i>0?1:0);j<c.length;j++)pts.push(c[j]);
}
return pts;
}
function extract(geom){
if(geom.type==="GeometryCollection"){
geom.geometries.forEach(extract);
}else if(geom.type==="Polygon"){
geom.arcs.forEach(function(ring){polys.push(ringFromArcs(ring))});
}else if(geom.type==="MultiPolygon"){
geom.arcs.forEach(function(polygon){
polygon.forEach(function(ring){polys.push(ringFromArcs(ring))});
});
}
}
extract(obj);
return polys;
}
function loadLand(cb){
if(_landPolygons)return cb(_landPolygons);
fetch("https://cdn.jsdelivr.net/npm/world-atlas@2/land-110m.json")
.then(function(r){return r.json()})
.then(function(topo){
_landPolygons=topoToPolygons(topo,"land");
cb(_landPolygons);
})
.catch(function(){cb([])});
}
function initGlobe(countries){
var canvas=document.getElementById("radio-globe");
if(!canvas)return;
var ctx=canvas.getContext("2d");
if(!ctx)return;
if(_globeAnim){cancelAnimationFrame(_globeAnim);_globeAnim=null;}
var size=canvas.offsetWidth||300;
if(size<10)return;
var dpr=Math.min(window.devicePixelRatio||1,2);
if(size<200)dpr=1;
var buf=size*dpr;
canvas.width=buf;
canvas.height=buf;
var R=buf*0.42;
var cx=buf/2,cy=buf/2;
var tilt=0.15;
var maxSessions=Math.max.apply(null,countries.map(function(c){return c.sessions}))||1;
var markers=[];
countries.forEach(function(c){
var coords=countryCoords[c.country_code];
if(!coords)return;
var lat=coords[0]*Math.PI/180;
var lng=coords[1]*Math.PI/180;
var s=3+6*(c.sessions/maxSessions);
markers.push({lat:lat,lng:lng,size:s*dpr});
});
var rotation=0;
function project(latD,lngD){
var lat=latD*Math.PI/180;
var lng=lngD*Math.PI/180+rotation;
var x=Math.cos(lat)*Math.sin(lng);
var y=Math.sin(lat)*Math.cos(tilt)-Math.cos(lat)*Math.cos(lng)*Math.sin(tilt);
var z=Math.sin(lat)*Math.sin(tilt)+Math.cos(lat)*Math.cos(lng)*Math.cos(tilt);
return{x:cx+x*R,y:cy-y*R,z:z};
}
function projectRad(lat,lng){
var l=lng+rotation;
var x=Math.cos(lat)*Math.sin(l);
var y=Math.sin(lat)*Math.cos(tilt)-Math.cos(lat)*Math.cos(l)*Math.sin(tilt);
var z=Math.sin(lat)*Math.sin(tilt)+Math.cos(lat)*Math.cos(l)*Math.cos(tilt);
return{x:cx+x*R,y:cy-y*R,z:z};
}
loadLand(function(land){
function draw(){
ctx.clearRect(0,0,buf,buf);
/* Glow */
var glow=ctx.createRadialGradient(cx,cy,R*0.8,cx,cy,R*1.2);
glow.addColorStop(0,"rgba(0,255,102,0.05)");
glow.addColorStop(1,"rgba(0,0,0,0)");
ctx.fillStyle=glow;
ctx.fillRect(0,0,buf,buf);
/* Ocean */
ctx.beginPath();
ctx.arc(cx,cy,R,0,Math.PI*2);
ctx.fillStyle="rgba(6,6,8,0.95)";
ctx.fill();
ctx.strokeStyle="rgba(0,255,102,0.18)";
ctx.lineWidth=1*dpr;
ctx.stroke();
ctx.save();
ctx.beginPath();
ctx.arc(cx,cy,R,0,Math.PI*2);
ctx.clip();
/* Land */
for(var i=0;i<land.length;i++){
var ring=land[i];
var visible=false;
ctx.beginPath();
for(var j=0;j<ring.length;j++){
var p=project(ring[j][1],ring[j][0]);
if(p.z>0)visible=true;
if(j===0)ctx.moveTo(p.x,p.y);
else ctx.lineTo(p.x,p.y);
}
ctx.closePath();
if(visible){
ctx.fillStyle="rgba(0,255,102,0.08)";
ctx.fill();
ctx.strokeStyle="rgba(0,255,102,0.22)";
ctx.lineWidth=0.5*dpr;
ctx.stroke();
}
}
ctx.restore();
/* Markers */
for(var j=0;j<markers.length;j++){
var m=markers[j];
var p=projectRad(m.lat,m.lng);
if(p.z<0)continue;
var alpha=0.3+0.7*p.z;
ctx.beginPath();
ctx.arc(p.x,p.y,m.size*2,0,Math.PI*2);
ctx.fillStyle="rgba(0,255,102,"+alpha*0.14+")";
ctx.fill();
ctx.beginPath();
ctx.arc(p.x,p.y,m.size,0,Math.PI*2);
ctx.fillStyle="rgba(0,255,102,"+alpha+")";
ctx.fill();
}
rotation+=0.003;
_globeAnim=requestAnimationFrame(draw);
}
draw();
canvas.classList.add("loaded");
});
}
window.addEventListener("resize",function(){
clearTimeout(_globeResizeTimer);
_globeResizeTimer=setTimeout(function(){
if(window._radioCountries)initGlobe(window._radioCountries);
},300);
});
renderRadioStats();
setInterval(renderRadioStats,10000);
</script>
</body>
</html>