Free internet radio above the prompt, and any song through hum's visible YouTube player, with the synced lyric line, a DJ Clawd and an optional big panel with…


Free internet radio and any song, in one quiet line above the Claude Code prompt, with a small DJ Clawd who listens along. When Claude asks you a question the music steps down, and it comes back when you answer.
native/hush.swift) plays audio with macOS's own AVPlayer. It is built on first use with swiftc, or downloaded from the GitHub release if Xcode's command line tools are missing. Songs need Node (for npx), see below./music start (or pause and resume) /music next next station (lofi, jazz, chill, classical, nature) /music vol 40 /music stop /music frank ocean nights play any song or artist, through hum /music jazz back to the radio (also /music radio) /music clawd show or hide DJ Clawd /music big switch between the player (the default) and the quiet one-line band
Press ctrl+x tab to focus the band: p pause, h l station (previous and next song while a song plays), j k volume, esc back.

Songs through hum get the full player by default; /music big switches to the quiet one-line band and back. The ✕ (or x while the band has focus) stops the music.
In the Claude app's Code tab the player is one picture: the cover and its light, the title, the lyric being sung over the next line, a progress bar and the time, and DJ Clawd working two decks at his booth. It moves by itself, so it is never redrawn while a song plays (no flicker), with native buttons for prev, play/pause, next, volume and close underneath. Hush remembers the choice. Radio always keeps its one-line band.
The panel shows the cover, a ribbon of aurora in the cover's colours, the title, the artist (and album), the lyric being sung over the next line as a karaoke preview, a progress bar with the times, and real prev, play or pause and next buttons that call hum. A song without synced lyrics never leaves a gap: its album takes the first line and "up next" (the next track in hum's queue) the second. DJ Clawd stays: he sits at the end of the buttons row, nods on tool calls and lifts a headphone cup when Claude asks you something. When the music steps down the glow dims and holds still.
HUSH_COVER=cells or image forces one). Quadrant blocks (▘▝▀▖▌▞▛▗▚▐▜▄▙▟█) carry 2x2 pixels in each cell in two colours, so the cover is twice as sharp across as half blocks, and it is given a little contrast, colour and an unsharp mask first so it reads crisply at 16 to 20 columns. The panel is as tall as the room allows, 8 to 10 rows (a 20 x 10 cover on a terminal of about 36 rows or more), and the cover stays square.#111111, the grid colour nearest hum's #08070b, so the dark cells of the glow and the card around them are one colour. Below 60 columns (or without the rows) the one-line band is drawn instead.The cover comes from hum's art (or YouTube's thumbnail), is downloaded with curl and resized with macOS's sips, and is cached in ~/.claude/hush/art/ (the 40 newest tracks). Nothing new is installed.
/plugin marketplace add anzal1/hush /plugin install hush@hush
or try it for one session with claude --plugin-dir /path/to/hush.
A mod runs with your full permissions. Read hooks/register.mjs and native/hush.swift first; they are short.
hooks/register.mjs is the mod. It talks to bin/hush over a unix socket (~/.claude/hush/h.sock). hooks/logic.mjs holds the pure parts: what a /music command means for radio or hum, and which lyric line is being sung and which comes next. hooks/panel.mjs holds the pure parts of the big panel: reading the cover's BMP, the palette, the quadrant-block cover, the dithered aurora, the eighth-block progress, the lyric layout, the SVG and which layout fits. The helper quits by itself 90 seconds after the last Claude Code session stops talking to it. If bin/hush is missing the mod builds it from native/hush.swift with swiftc. Rebuild with ./build.sh.
Ducking listens for tool.check (a verdict of "ask") and AskUserQuestion, and restores when the tool result is recorded, the turn ends, or you submit a prompt. Claude Code's built-in guard hides the classic.* events from installed mods, so Hush does not rely on them.
Public streams: NIA Radio Lo-Fi, SmoothJazz.com, 0 N Chillout, Radio Swiss Classic, MyNoise Pure Nature. SomaFM is left out on purpose because its terms prohibit third-party clients. These are public streams found through the Radio Browser directory. The stations have not agreed to be in Hush, so use it as you would any radio app, for personal listening. To change the list, edit STATIONS at the top of hooks/register.mjs.
/music <song or artist> hands the song to hum, a free player that plays through YouTube's official embed in a window you can see. Hush does not download or re-stream anything: the song plays in YouTube's own player, so the artist is paid as for any other view, and you can see what is playing.
If hum is not running, Hush starts it (npx -y -p github:anzal1/hum hum, so Node is needed) and opens its player in your browser, then sends the song once the window connects. The first start downloads hum and can take longer than the 20 seconds Hush waits. If that happens, start it yourself with the same command and run /music again. Hush talks to hum's loopback remote on 127.0.0.1:3737; set HUM_PORT to use another port.
While a song plays the band shows the title and artist and the synced lyric line being sung. Ducking, pause, next and previous, volume and stop all go to hum. /music jazz or /music radio pauses hum and returns to the radio. Keep the hum window open; the sound comes from it.
A tiny headphone-wearing Clawd sits at the right of the row while music plays. He walks in when a song starts, nods on tool calls, and lifts one headphone cup when Claude asks you a question (the music steps down at the same moment). He needs about 84 columns, and /music clawd turns him off.
Plugins from a GitHub marketplace do not auto-update by default. Turn it on in /plugin, on the Marketplaces tab, with Enable auto-update on hush. Or update by hand:
claude plugin update hush@hush
then run /reload-plugins or restart. Mods need Claude Code 2.1.287 or later; update that with claude update.
claude plugin test .
runs everything in tests/ against fake radio and hum servers, so nothing plays.
hooks/register.mjs 1423 lines1// Hush: free radio and any song, in one quiet line above the prompt, with a small
2// DJ Clawd who listens along.
3//
4// Radio comes from bin/hush, a small native helper that streams with the system
5// AVPlayer and listens on a unix socket. Songs come from hum, a free player that plays
6// through YouTube's own embed in a window you can see. Hush starts hum when it is not
7// running and talks to its loopback remote over HTTP. hooks/logic.mjs holds the pure parts.
8//
9// /music start the last station, or pause and resume
10// /music <song or artist> play it in hum: /music frank ocean nights
11// /music next | prev | pause | stop
12// /music lofi | jazz | chill | classical | nature | radio
13// /music vol 40
14// /music clawd show or hide DJ Clawd
15// /music big switch between the big panel (the default) and the one-line band for songs
16//
17// When Claude asks a permission question the music steps down and Clawd lifts one
18// headphone cup. Both come back when the question is answered. The host reads
19// on(...) and $.noun.method(...) from source, so they are written out in full.
20//
21// The big panel is on by default. It draws the cover, the title, artist, lyric line,
22// progress and buttons while a song plays through hum. The terminal gets cells and an aurora
23// (hooks/panel.mjs holds its pure parts) and a picture where it can show one. The desktop gets
24// one SVG, the stage (hooks/stage.mjs), that moves by itself. Radio keeps the one-line band.
25
26import { stageSvg, STAGE_HEIGHT } from "./stage.mjs";
27import { clamp, duckCommand, humView, livePosition, parseArgs, route, VOLUME_STEP } from "./logic.mjs";
28import {
29 auroraCells,
30 BONE,
31 BONE_DIM,
32 BONE_SOFT,
33 canShowImages,
34 captionLines,
35 chooseLayout,
36 clock,
37 COVER_KEEP,
38 COVER_SCRIPT,
39 coverKey,
40 coverUrls,
41 encodeCells,
42 FALLBACK_ACCENT,
43 FALLBACK_COLORS,
44 fit,
45 fromBase64,
46 hex,
47 mix,
48 PANEL_BG,
49 PANEL_BG_HEX,
50 PANEL_MAX_COLUMNS,
51 PANEL_PAD,
52 palette,
53 panelGeometry,
54 parseBmp,
55 pillColor,
56 placeholderCells,
57 progressRuns,
58 quadrantCells,
59 timeLabels,
60 tone,
61 TRACK,
62} from "./panel.mjs";
63
64const STATIONS = [
65 { id: "lofi", name: "lofi", color: "#d97757", url: "https://radio.nia.nc/radio/8020/lofi-hq-stream.aac" },
66 { id: "jazz", name: "jazz", color: "#e0af68", url: "https://smoothjazz.cdnstream1.com/2585_128.mp3" },
67 { id: "chill", name: "chill", color: "#9b87f5", url: "https://0n-chillout.radionetz.de/0n-chillout.aac" },
68 { id: "classical", name: "classical", color: "#7fb7a0", url: "https://stream.srg-ssr.ch/m/rsc_de/aacp_96" },
69 { id: "nature", name: "nature", color: "#6fa8c7", url: "https://purenature-mynoise.radioca.st/stream" },
70];
71const HUM_COLOR = "#d97757";
72const CLAWD_COLOR = "#d97757";
73
74const RELEASE_URL = "https://github.com/anzal1/hush/releases/latest/download/hush-macos";
75const HUM_INSTALL = "github:anzal1/hum";
76const HUM_DEFAULT_PORT = 3737;
77const POLL_MS = 2500;
78const HUM_POLL_MS = 1000;
79const HUM_STATE_MS = 1500;
80const HUM_PLAY_MS = 25000;
81const HUM_WAIT_MS = 90000;
82// How long /music waits before answering and finishing in the background.
83const ANSWER_MS = 7000;
84const HUM_MISSES = 10;
85const BAR_CELLS = 10;
86const COLLAPSE_CELLS = 4;
87const MAX_COLUMNS = 100;
88const CLAWD_ZONE = 12;
89const AURORA_MS = 125;
90const AURORA_MISSES = 20;
91const AURORA_STALE_MS = 4000;
92const COVER_MS = 30000;
93const PROBE_MS = 1500;
94const EPOCH = Date.now();
95const SESSION = Math.random().toString(36).slice(2, 8);
96
97// What the radio helper last said: { state, station, title, volume, ducked, pos, dur }.
98let status = { state: "off", station: "", title: "", volume: 55, ducked: false, pos: 0, dur: 0 };
99let stationIndex = 0;
100let source = "radio";
101let hum = null;
102let humPort = HUM_DEFAULT_PORT;
103let humMisses = 0;
104let humBusy = false;
105let humDucked = false;
106let humSeen = "";
107let finding = "";
108let timer = null;
109let timerMs = 0;
110let failures = 0;
111let isLit = false;
112let clawdOn = true;
113let big = true; // the panel is the default; /music big toggles the quiet band
114let canImages = false;
115let cover = emptyCover("");
116let panelSite = null;
117let auroraTimer = null;
118let auroraBusy = false;
119let auroraMisses = 0;
120let auroraBlocked = "";
121let lineTimer = null;
122let imagesBlocked = false;
123let probed = "";
124let walk = CLAWD_ZONE - 7;
125let nod = false;
126
127export function register(on) {
128 on("session.start", async ($, e, next) => {
129 const started = await next(e);
130 await $.command.register({
131 name: "music",
132 description: "Free radio and any song above the prompt",
133 argumentHint: "[song or artist | next | prev | pause | stop | lofi | jazz | chill | classical | nature | radio | vol <0-100> | clawd]",
134 immediate: true,
135 });
136 const kept = await $.store.get("station").catch(() => undefined);
137 if (typeof kept === "number" && kept >= 0 && kept < STATIONS.length) {
138 stationIndex = kept;
139 }
140 const saved = await $.store.get("volume").catch(() => undefined);
141 if (typeof saved === "number") {
142 status.volume = saved;
143 }
144 const port = Number.parseInt((await $.env.get("HUM_PORT").catch(() => undefined)) ?? "", 10);
145 humPort = port > 0 && port < 65536 ? port : HUM_DEFAULT_PORT;
146 const clawd = await $.store.get("clawd").catch(() => undefined);
147 if (typeof clawd === "boolean") {
148 clawdOn = clawd;
149 }
150 const wide = await $.store.get("big").catch(() => undefined);
151 if (typeof wide === "boolean") {
152 big = wide;
153 }
154 const term = {
155 TERM_PROGRAM: (await $.env.get("TERM_PROGRAM").catch(() => undefined)) ?? "",
156 TERM: (await $.env.get("TERM").catch(() => undefined)) ?? "",
157 KITTY_WINDOW_ID: (await $.env.get("KITTY_WINDOW_ID").catch(() => undefined)) ?? "",
158 GHOSTTY_RESOURCES_DIR: (await $.env.get("GHOSTTY_RESOURCES_DIR").catch(() => undefined)) ?? "",
159 TMUX: (await $.env.get("TMUX").catch(() => undefined)) ?? "",
160 HUSH_COVER: (await $.env.get("HUSH_COVER").catch(() => undefined)) ?? "",
161 };
162 canImages = canShowImages(term);
163 const running = await ask($, "status");
164 if (running && (running.state === "playing" || running.state === "loading" || running.state === "paused")) {
165 adopt(running);
166 watch($);
167 }
168 return started;
169 });
170
171 // A command hook has ten seconds. Starting hum (the first time it is also downloaded) and
172 // waiting for its tab can take longer, so answer in time and let the rest finish on its own;
173 // the outcome arrives as a toast.
174 on("command.run", { command: "music" }, async ($, e) => {
175 const work = runCommand($, e.args.trim());
176 const text = await Promise.race([work, $.clock.sleep(ANSWER_MS).then(() => null)]);
177 if (text !== null) {
178 return { text };
179 }
180 work.then((t) => t && $.ui.toast(`hush: ${t}`)).catch(() => undefined);
181 return { text: "Starting hum. The song plays in the hum tab once it connects; click that tab once if your browser asks." };
182 });
183
184 // Claude Code's own guard hides classic.PermissionRequest from installed mods, so the
185 // question is caught here instead: the verdict "ask" means a dialog is about to open.
186 on("tool.check", async ($, e, next) => {
187 const verdict = await next(e);
188 if (verdict && verdict.decision === "ask") {
189 await duck($, true);
190 }
191 return verdict;
192 });
193
194 on("tool.call", { tool: "AskUserQuestion" }, async ($, e, next) => {
195 await duck($, true);
196 return next(e);
197 });
198
199 on("tool.call", async ($, e, next) => {
200 if (clawdOn && isPlaying() && !isDucked()) {
201 nod = true;
202 $.ui.invalidate("ui.render");
203 $.clock.after(220, () => {
204 nod = false;
205 $.ui.invalidate("ui.render");
206 });
207 }
208 return next(e);
209 });
210
211 // The classic.* events are hidden from installed mods when an organization guard is loaded, so
212 // the music comes back on events that are not: a tool result being recorded, a turn ending, or
213 // a new prompt.
214 on("session.append", { door: "tool-result" }, async ($, e, next) => {
215 await duck($, false);
216 return next(e);
217 });
218
219 on("turn.complete", async ($, e, next) => {
220 await duck($, false);
221 return next(e);
222 });
223
224 on("prompt.submit", async ($, e, next) => {
225 await duck($, false);
226 return next(e);
227 });
228
229 on("classic.PostToolUse", async ($, e, next) => {
230 await duck($, false);
231 return next(e);
232 });
233
234 on("classic.PostToolUseFailure", async ($, e, next) => {
235 await duck($, false);
236 return next(e);
237 });
238
239 on("classic.PermissionDenied", async ($, e, next) => {
240 await duck($, false);
241 return next(e);
242 });
243
244 on("classic.Stop", async ($, e, next) => {
245 await duck($, false);
246 return next(e);
247 });
248
249 on("classic.UserPromptSubmit", async ($, e, next) => {
250 await duck($, false);
251 return next(e);
252 });
253
254 on("ui.focus", async ($, e, next) => {
255 const wasLit = isLit;
256 isLit = e.component === "AbovePrompt" && e.plugin === $.plugin.name && Boolean(e.element);
257 if (isLit !== wasLit) {
258 $.ui.invalidate("ui.render");
259 }
260 return next(e);
261 });
262
263 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
264 lastSurface = e.surface;
265 if (e.props.hasSurvey || !isActive()) {
266 leavePanel($, e);
267 return next(e);
268 }
269 const ui = $.ui.resolve(e);
270 const width = e.props.bodyColumns ?? 80;
271 const columns = Math.min(width - COLLAPSE_CELLS, MAX_COLUMNS);
272 const panelColumns = Math.min(width - COLLAPSE_CELLS, PANEL_MAX_COLUMNS);
273 const song = source === "hum" && finding === "" ? humView(hum, now()) : null;
274 const layout = chooseLayout({
275 big,
276 isSong: song !== null,
277 surface: e.surface,
278 columns: width,
279 rows: e.props.maxRows ?? 0,
280 canImages: canImages && !imagesBlocked,
281 });
282 const beneath = await next(e);
283 if (layout.mode === "panel") {
284 const drawn = e.surface === "desktop" ? desktopPanel($, ui, song, columns) : terminalPanel($, ui, e, layout, song, panelColumns);
285 return ui.Box({ flexDirection: "column", children: [drawn, beneath] });
286 }
287 leavePanel($, e);
288 return ui.Box({ flexDirection: "column", children: [band($, ui, columns), beneath] });
289 });
290
291 on("session.end", async ($, e, next) => {
292 await duck($, false);
293 stop();
294 return next(e);
295 });
296}
297
298// ---------- helper process ----------
299
300async function paths($) {
301 const home = await $.env.get("HOME");
302 const dir = `${home}/.claude/hush`;
303 return { bin: `${$.plugin.root}/bin/hush`, socket: `${dir}/h.sock` };
304}
305
306// Sends one command; resolves the helper's JSON reply, or null when it is not running.
307async function ask($, command) {
308 const { bin, socket } = await paths($);
309 try {
310 const run = await $.process.run([bin, "ctl", socket, command], { timeoutMs: 4000 });
311 if (run.exitCode !== 0) {
312 return null;
313 }
314 return JSON.parse(run.stdout.trim());
315 } catch {
316 return null;
317 }
318}
319
320async function exists($, path) {
321 const run = await $.process.run(["/bin/test", "-x", path]).catch(() => ({ exitCode: 1 }));
322 return run.exitCode === 0;
323}
324
325// Makes sure the helper runs, building it from source (or downloading it) when the binary is missing.
326async function ensureHelper($) {
327 if (await ask($, "status")) {
328 return true;
329 }
330 const { bin, socket } = await paths($);
331 const root = $.plugin.root;
332 if (!(await exists($, bin))) {
333 const built = (await exists($, "/usr/bin/swiftc"))
334 ? await $.process
335 .run(["/usr/bin/swiftc", "-O", `${root}/native/hush.swift`, "-o", bin], { timeoutMs: 180000 })
336 .catch(() => ({ exitCode: 1 }))
337 : await download($, RELEASE_URL, bin);
338 if (built.exitCode !== 0) {
339 return false;
340 }
341 }
342 await $.process.run(
343 ["/bin/sh", "-c", 'mkdir -p "$(dirname "$1")"; nohup "$0" daemon "$1" >/dev/null 2>&1 &', bin, socket],
344 { timeoutMs: 5000 },
345 );
346 for (let i = 0; i < 25; i += 1) {
347 await $.clock.sleep(120);
348 if (await ask($, "status")) {
349 return true;
350 }
351 }
352 return false;
353}
354
355async function download($, url, target) {
356 return $.process
357 .run(["/bin/sh", "-c", 'mkdir -p "$(dirname "$2")" && curl -fsSL "$1" -o "$2" && chmod +x "$2"', "sh", url, target], {
358 timeoutMs: 240000,
359 })
360 .catch(() => ({ exitCode: 1 }));
361}
362
363// ---------- hum ----------
364
365function humUrl(path) {
366 return `http://127.0.0.1:${humPort}${path}`;
367}
368
369function now() {
370 return Date.now();
371}
372
373// One request to hum's loopback remote. Resolves the parsed JSON, or null when hum does not answer in time.
374async function humFetch($, path, init, ms) {
375 let timeout;
376 const late = new Promise((resolve) => {
377 timeout = $.clock.after(ms, () => resolve(null));
378 });
379 const reply = await Promise.race([$.http.fetch(humUrl(path), init).catch(() => null), late]);
380 timeout.cancel();
381 if (!reply || !reply.ok) {
382 return null;
383 }
384 try {
385 return JSON.parse(reply.text);
386 } catch {
387 return null;
388 }
389}
390
391async function humState($) {
392 return humFetch($, "/api/remote/state", undefined, HUM_STATE_MS);
393}
394
395async function humSend($, body, ms = HUM_STATE_MS) {
396 const init = { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
397 return humFetch($, "/api/remote", init, ms);
398}
399
400// Puts npx on PATH however Node was installed. A mod's shell does not load the person's shell
401// setup, so a Node that only nvm, Volta, fnm, mise or asdf knows about is otherwise invisible;
402// the last resort asks the person's own login shell where npx is.
403const FIND_NODE = [
404 'H="${HOME:-$(cd ~ && pwd)}";',
405 'PATH="$PATH:/opt/homebrew/bin:/usr/local/bin:$H/.volta/bin:$H/.local/share/mise/shims:$H/.asdf/shims";',
406 'd=$(ls -1d "$H"/.nvm/versions/node/*/bin "$H"/.local/share/fnm/node-versions/*/installation/bin "$H/Library/Application Support/fnm/node-versions"/*/installation/bin 2>/dev/null | sort -r | while IFS= read -r x; do [ -x "$x/npx" ] && { echo "$x"; break; }; done);',
407 '[ -n "$d" ] && PATH="$d:$PATH";',
408 'command -v npx >/dev/null 2>&1 || { p=$("${SHELL:-/bin/zsh}" -ilc "command -v npx" 2>/dev/null | tail -1); [ -n "$p" ] && PATH="$(dirname "$p"):$PATH"; };',
409 "",
410].join(" ");
411
412// Starts hum's server, detached. hum only opens its own tab when it has a terminal, so
413// HUM_NO_OPEN is set and Hush opens the visible player itself once the server answers.
414async function startHum($) {
415 await $.process
416 .run(
417 [
418 "/bin/sh",
419 "-c",
420 FIND_NODE + 'HUM_NO_OPEN=1 PORT="$1" nohup npx -y -p "$2" hum >/dev/null 2>&1 &',
421 "sh",
422 String(humPort),
423 HUM_INSTALL,
424 ],
425 { timeoutMs: 5000 },
426 )
427 .catch(() => undefined);
428}
429
430async function openHum($) {
431 await $.process.run(["/usr/bin/open", `http://localhost:${humPort}`], { timeoutMs: 5000 }).catch(() => undefined);
432}
433
434// Makes sure a hum window is open: starts the server when nothing answers, opens the player
435// when the server has no window, then waits for a window to connect.
436async function ensureHum($) {
437 let state = await humState($);
438 if (state && state.connected > 0) {
439 return "";
440 }
441 finding = "opening hum…";
442 $.ui.invalidate("ui.render");
443 if (!state) {
444 await startHum($);
445 }
446 let opened = false;
447 for (let waited = 0; ; waited += 500) {
448 if (state && state.connected > 0) {
449 return "";
450 }
451 if (state && !opened) {
452 await openHum($);
453 opened = true;
454 }
455 if (waited >= HUM_WAIT_MS) {
456 break;
457 }
458 await $.clock.sleep(500);
459 state = await humState($);
460 }
461 if (state) {
462 return `hum is running but its player window is not open. Open http://localhost:${humPort} and run /music again.`;
463 }
464 return `hum needs to be open to play songs. Start it with: npx -y -p ${HUM_INSTALL} hum, then run /music again.`;
465}
466
467// Sends a song to hum, which plays it in its own visible YouTube player.
468async function playOnHum($, cmd) {
469 finding = `finding ${cmd.query}…`;
470 $.ui.invalidate("ui.render");
471 try {
472 const problem = await ensureHum($);
473 if (problem) {
474 return problem;
475 }
476 finding = `finding ${cmd.query}…`;
477 $.ui.invalidate("ui.render");
478 await quietRadio($);
479 const reply = await humSend($, cmd, HUM_PLAY_MS);
480 if (!reply) {
481 return "hum did not answer. Check that its player window is still open.";
482 }
483 if (!reply.ok) {
484 return reply.text || "hum could not play that.";
485 }
486 source = "hum";
487 humMisses = 0;
488 humSeen = "";
489 hum = (await humState($)) ?? hum;
490 watch($);
491 cue($);
492 wantCover($);
493 return reply.text || `Playing ${cmd.query} in hum`;
494 } finally {
495 finding = "";
496 $.ui.invalidate("ui.render");
497 }
498}
499
500// Stops the radio before a song starts, so the two never play together.
501async function quietRadio($) {
502 stopTimer();
503 if (status.state === "playing" || status.state === "loading" || status.state === "paused") {
504 await ask($, "stop");
505 }
506 status = { ...status, state: "off", title: "", ducked: false, pos: 0, dur: 0 };
507}
508
509// Hands the sound back to the radio: hum is paused (and restored if it was stepped down).
510async function leaveHum($) {
511 if (source !== "hum") {
512 return;
513 }
514 const view = humView(hum, now());
515 if (view && view.state === "playing") {
516 await humSend($, { cmd: "pause" });
517 }
518 if (humDucked) {
519 await humSend($, { cmd: "unduck" });
520 }
521 dropHum();
522}
523
524function dropHum() {
525 stopTimer();
526 stopAurora();
527 stopLine();
528 cover = emptyCover("");
529 source = "radio";
530 hum = null;
531 humDucked = false;
532 humMisses = 0;
533 humSeen = "";
534}
535
536async function pollHum($) {
537 if (humBusy || source !== "hum") {
538 return;
539 }
540 humBusy = true;
541 try {
542 const state = await humState($);
543 if (source !== "hum") {
544 return;
545 }
546 const view = humView(state, now());
547 if (!view) {
548 humMisses += 1;
549 if (state) {
550 hum = state;
551 }
552 if (humMisses >= HUM_MISSES) {
553 stop();
554 }
555 $.ui.invalidate("ui.render");
556 return;
557 }
558 humMisses = 0;
559 const before = humView(hum, now());
560 hum = state;
561 if (!before || before.title !== view.title) {
562 cue($);
563 }
564 wantCover($);
565 syncAurora($);
566 // the desktop picture carries the lyric and the time itself; a redraw there only for what it cannot know
567 const seen =
568 lastSurface === "desktop" && big
569 ? [view.name, view.album, view.upNext, view.state, view.ducked, view.volume, view.hasLyrics, cover.key, Boolean(cover.jpg)].join("|")
570 : [view.name, view.line, view.next, view.album, view.upNext, view.state, view.ducked, view.volume, Math.floor(view.pos)].join("|");
571 if (seen !== humSeen) {
572 humSeen = seen;
573 $.ui.invalidate("ui.render");
574 }
575 scheduleLine($, view);
576 } finally {
577 humBusy = false;
578 }
579}
580
581// ---------- commands ----------
582
583async function runCommand($, args) {
584 return perform($, parseArgs(args, STATIONS.map((s) => s.id)));
585}
586
587// Works out what an intent means for the current source, then does it.
588async function perform($, intent) {
589 const view = humView(hum, now());
590 const plan = route(intent, {
591 source,
592 humLive: view !== null,
593 radioActive: isActive(),
594 radioVolume: status.volume,
595 humVolume: view ? view.volume : 0,
596 stationIndex,
597 });
598 if (plan.target === "hum") {
599 return performHum($, plan);
600 }
601 if (plan.target === "radio") {
602 return performRadio($, plan);
603 }
604 if (plan.action === "big") {
605 big = !big;
606 await $.store.set("big", big).catch(() => undefined);
607 if (big) {
608 wantCover($);
609 } else {
610 panelSite = null;
611 auroraBlocked = "";
612 stopAurora();
613 }
614 $.ui.invalidate("ui.render");
615 return big
616 ? "Big panel on. Songs through hum get it; radio keeps its band"
617 : "Big panel off. Back to the one-line band";
618 }
619 clawdOn = !clawdOn;
620 await $.store.set("clawd", clawdOn).catch(() => undefined);
621 $.ui.invalidate("ui.render");
622 return clawdOn ? "DJ Clawd is back" : "DJ Clawd is off";
623}
624
625async function performHum($, plan) {
626 if (plan.action === "play") {
627 return playOnHum($, plan.cmd);
628 }
629 if (plan.action === "volume") {
630 if (!plan.cmd) {
631 return `Volume is ${humView(hum, now())?.volume ?? 0}. Use /music vol 0-100`;
632 }
633 hum = { ...hum, volume: plan.level };
634 $.ui.invalidate("ui.render");
635 await humSend($, plan.cmd);
636 return `Volume ${plan.level}`;
637 }
638 const reply = await humSend($, plan.cmd);
639 if (plan.action === "stop") {
640 dropHum();
641 $.ui.invalidate("ui.render");
642 return "Music stopped";
643 }
644 if (!reply) {
645 return "hum did not answer. Check that its player window is still open.";
646 }
647 if (!reply.ok) {
648 return reply.text || "hum could not do that.";
649 }
650 if (plan.action === "next" || plan.action === "prev") {
651 return reply.text || (plan.action === "next" ? "Next song" : "Previous song");
652 }
653 const playing = plan.cmd.cmd === "resume" || (plan.cmd.cmd === "toggle" && hum && !hum.playing);
654 hum = { ...hum, playing, position: livePosition(hum, now()), at: now() };
655 syncAurora($);
656 $.ui.invalidate("ui.render");
657 return playing ? "Playing" : "Paused";
658}
659
660async function performRadio($, plan) {
661 await leaveHum($);
662 if (plan.action === "station") {
663 return startStation($, plan.index);
664 }
665 if (plan.action === "next") {
666 return startStation($, stationIndex + 1);
667 }
668 if (plan.action === "prev") {
669 return startStation($, stationIndex - 1);
670 }
671 if (plan.action === "stop") {
672 await ask($, "stop");
673 stop();
674 $.ui.invalidate("ui.render");
675 return "Music stopped";
676 }
677 if (plan.action === "volume") {
678 if (plan.level === null) {
679 return `Volume is ${status.volume}. Use /music vol 0-100`;
680 }
681 await setVolume($, plan.level);
682 return `Volume ${status.volume}`;
683 }
684 return toggle($);
685}
686
687async function toggle($) {
688 await togglePause($);
689 return status.state === "paused" ? "Paused" : "Playing";
690}
691
692async function startStation($, index) {
693 stationIndex = (index + STATIONS.length) % STATIONS.length;
694 const station = STATIONS[stationIndex];
695 if (!(await ensureHelper($))) {
696 return "Hush could not start its player. It needs macOS, and either Xcode's command line tools or a network connection to GitHub.";
697 }
698 await ask($, `vol ${status.volume}`);
699 await ask($, "resume");
700 const reply = await ask($, `play ${station.url} ${station.name}`);
701 if (reply) {
702 adopt(reply);
703 }
704 await $.store.set("station", stationIndex).catch(() => undefined);
705 watch($);
706 cue($);
707 $.ui.invalidate("ui.render");
708 return `Playing ${station.name}. ctrl+x tab for controls`;
709}
710
711async function togglePause($) {
712 const paused = status.state === "paused";
713 const reply = await ask($, paused ? "resume" : "pause");
714 if (reply) {
715 adopt(reply);
716 }
717 $.ui.invalidate("ui.render");
718}
719
720async function setVolume($, value) {
721 const clamped = clamp(value, 0, 100);
722 status = { ...status, volume: clamped };
723 await ask($, `vol ${clamped}`);
724 await $.store.set("volume", clamped).catch(() => undefined);
725 $.ui.invalidate("ui.render");
726}
727
728// Steps the sound down for a question and back up after, on whichever source is playing.
729async function duck($, wanted) {
730 if (source === "hum") {
731 const body = duckCommand(wanted, humDucked);
732 if (!body || (wanted && !isActive())) {
733 return;
734 }
735 humDucked = wanted;
736 if (hum) {
737 hum = { ...hum, ducked: wanted };
738 }
739 syncAurora($);
740 $.ui.invalidate("ui.render");
741 await humSend($, body);
742 return;
743 }
744 if (!isActive() || status.ducked === wanted) {
745 return;
746 }
747 status = { ...status, ducked: wanted };
748 $.ui.invalidate("ui.render");
749 await ask($, `${wanted ? "duck" : "unduck"} ${SESSION}`);
750}
751
752// ---------- state ----------
753
754function isActive() {
755 if (finding !== "") {
756 return true;
757 }
758 if (source === "hum") {
759 return humView(hum, now()) !== null;
760 }
761 return status.state === "playing" || status.state === "loading" || status.state === "paused";
762}
763
764function isPlaying() {
765 if (source === "hum") {
766 return humView(hum, now())?.state === "playing";
767 }
768 return status.state === "playing";
769}
770
771function isDucked() {
772 return source === "hum" ? humDucked || Boolean(hum?.ducked) : status.ducked;
773}
774
775function adopt(reply) {
776 const index = STATIONS.findIndex((s) => s.name === reply.station);
777 if (index >= 0) {
778 stationIndex = index;
779 }
780 status = { ...status, ...reply };
781}
782
783// Polls the source that is playing: the radio helper every 2.5 s, hum every second.
784function watch($) {
785 const ms = source === "hum" ? HUM_POLL_MS : POLL_MS;
786 if (timer && timerMs === ms) {
787 return;
788 }
789 stopTimer();
790 timerMs = ms;
791 timer = $.clock.every(ms, () => {
792 void (source === "hum" ? pollHum($) : poll($));
793 });
794}
795
796function stopTimer() {
797 if (timer) {
798 timer.cancel();
799 timer = null;
800 }
801}
802
803function stop() {
804 dropHum();
805 finding = "";
806 status = { ...status, state: "off", title: "", ducked: false, pos: 0, dur: 0 };
807}
808
809async function poll($) {
810 const reply = await ask($, "status");
811 if (!reply || reply.state === "idle") {
812 stop();
813 $.ui.invalidate("ui.render");
814 return;
815 }
816 if (reply.state === "failed") {
817 failures += 1;
818 if (failures <= STATIONS.length) {
819 await startStation($, stationIndex + 1);
820 return;
821 }
822 stop();
823 await ask($, "stop");
824 $.ui.invalidate("ui.render");
825 return;
826 }
827 failures = 0;
828 const moved = reply.state === "playing" && Math.abs((reply.pos ?? 0) - (status.pos ?? 0)) >= 2;
829 const changed = reply.state !== status.state || reply.title !== status.title || reply.ducked !== status.ducked || moved;
830 adopt(reply);
831 if (changed) {
832 $.ui.invalidate("ui.render");
833 }
834}
835
836// DJ Clawd walks in from the left of his spot whenever a new song or station starts.
837function cue($) {
838 if (!clawdOn) {
839 return;
840 }
841 walk = 0;
842 const step = () => {
843 walk += 1;
844 $.ui.invalidate("ui.render");
845 if (walk < CLAWD_ZONE - 7) {
846 $.clock.after(120, step);
847 }
848 };
849 $.clock.after(120, step);
850}
851
852// ---------- drawing ----------
853
854// A hotkey makes the engine draw a "h:" prefix, so only the focused band carries them.
855function keyed(letter) {
856 return isLit ? { hotkey: letter } : {};
857}
858
859// DJ Clawd: the banner mascot's head with headphones. One cup lifts when you are being asked something.
860function clawdFace() {
861 if (isDucked()) {
862 return "(▐▛█▜▌ '";
863 }
864 return nod ? "(▐▙█▟▌)" : "(▐▛█▜▌)";
865}
866
867// One line: note, title, a tag, a bar, buttons, DJ Clawd. Narrower terminals drop the
868// bar first, then the tag, then DJ Clawd, then the buttons.
869function band($, ui, columns) {
870 const { Box, Text, Button } = ui;
871 const song = source === "hum" ? humView(hum, now()) : null;
872 const isSong = song !== null;
873 const view = song ?? status;
874 const station = STATIONS[stationIndex];
875 const accent = isSong ? HUM_COLOR : station.color;
876 const ducked = isDucked();
877 const quiet = ducked || view.state === "paused";
878 const loading = finding !== "" || view.state === "loading";
879 let title = status.title || `${station.name} radio`;
880 if (finding) {
881 title = finding;
882 } else if (isSong) {
883 title = song.name;
884 } else if (status.state === "loading") {
885 title = "tuning…";
886 }
887 const tag = isSong ? (view.dur > 0 ? `${clock(view.pos)} / ${clock(view.dur)}` : "") : `${station.name} · live`;
888 // A song with lyrics gives the bar's room to the lyric line; the clock in the tag shows progress.
889 const showBar = columns >= 70 && !(isSong && song.hasLyrics);
890 const showTag = columns >= 56 && tag !== "";
891 const showButtons = columns >= 44;
892 const showClawd = clawdOn && columns >= 84 && view.state === "playing" && !finding;
893 const lit = isLit && showButtons;
894 const showVolButtons = lit && columns >= 70;
895 const barText = showBar ? BAR_CELLS + 1 : 0;
896 const tagText = showTag ? tag.length + 2 : 0;
897 let buttonsText = 0;
898 if (showButtons) {
899 buttonsText = showVolButtons ? 40 : lit ? 24 : 12;
900 }
901 const clawdText = showClawd ? CLAWD_ZONE + 2 : 0;
902 const titleCells = Math.max(8, columns - 2 - tagText - barText - buttonsText - clawdText - 1);
903
904 const parts = [Text({ color: accent, dimColor: quiet, children: "♪ " })];
905 if (isSong && song.hasLyrics && !finding && titleCells >= 40) {
906 // Title and artist keep a fixed share, so the buttons stay put while the lyric changes.
907 const nameCells = Math.min(song.name.length, Math.max(16, Math.floor(titleCells * 0.5)));
908 const lyricCells = titleCells - nameCells - 2;
909 parts.push(Text({ dimColor: quiet, children: fit(song.name, nameCells).padEnd(nameCells) }));
910 parts.push(Text({ children: " " }));
911 parts.push(Text({ color: accent, dimColor: quiet, italic: true, children: fit(song.line, lyricCells).padEnd(lyricCells) }));
912 } else {
913 parts.push(Text({ dimColor: quiet || loading, children: fit(title, titleCells).padEnd(titleCells) }));
914 }
915 if (showTag) {
916 parts.push(Text({ dimColor: true, children: ` ${tag}` }));
917 }
918 if (showBar) {
919 let filled;
920 if (isSong) {
921 filled = view.dur > 0 ? Math.round((view.pos / view.dur) * BAR_CELLS) : 0;
922 } else {
923 const effective = ducked ? Math.round(status.volume * 0.35) : status.volume;
924 filled = status.state === "paused" ? 0 : Math.round((effective / 100) * BAR_CELLS);
925 }
926 filled = clamp(filled, 0, BAR_CELLS);
927 parts.push(Text({ children: " " }));
928 parts.push(Text({ color: accent, dimColor: quiet, children: "━".repeat(filled) }));
929 parts.push(Text({ dimColor: true, children: "─".repeat(BAR_CELLS - filled) }));
930 }
931 if (showButtons) {
932 parts.push(Text({ children: " " }));
933 parts.push(
934 Button({ key: "prev", label: "‹", ...keyed("h"), plain: true, dimColor: !lit, onPress: () => void press($, "prev") }),
935 );
936 parts.push(Text({ children: " " }));
937 parts.push(
938 Button({
939 key: "play",
940 label: view.state === "paused" ? "play" : "pause",
941 ...keyed("p"),
942 plain: true,
943 autoFocus: true,
944 dimColor: !lit,
945 onPress: () => void press($, "pause"),
946 }),
947 );
948 parts.push(Text({ children: " " }));
949 parts.push(
950 Button({ key: "next", label: "›", ...keyed("l"), plain: true, dimColor: !lit, onPress: () => void press($, "next") }),
951 );
952 if (showVolButtons) {
953 parts.push(Text({ children: " " }));
954 parts.push(Button({ key: "down", label: "-", hotkey: "j", plain: true, onPress: () => void press($, "down") }));
955 parts.push(Text({ dimColor: true, children: ` vol ${view.volume} ` }));
956 parts.push(Button({ key: "up", label: "+", hotkey: "k", plain: true, onPress: () => void press($, "up") }));
957 }
958 }
959 if (showClawd) {
960 parts.push(Text({ children: " ".repeat(Math.max(1, walk + 1)) }));
961 parts.push(Text({ color: CLAWD_COLOR, children: clawdFace() }));
962 }
963 const row = Box({ flexDirection: "row", paddingX: 1, children: parts });
964 if (!lit) {
965 return row;
966 }
967 const hint = Text({
968 dimColor: true,
969 children: showVolButtons
970 ? ` p pause · h l ${isSong ? "song" : "station"} · j k volume · esc back to the prompt`
971 : ` p pause · h l ${isSong ? "song" : "station"} · esc back to the prompt`,
972 });
973 return Box({ flexDirection: "column", children: [row, hint] });
974}
975
976// ---------- big panel ----------
977
978function emptyCover(key) {
979 return { key, state: "none", png: "", jpg: "", thumb: null, cells: "", cellsKey: "", colors: FALLBACK_COLORS, accent: FALLBACK_ACCENT };
980}
981
982// Starts reading the cover of the song that plays, when the big panel is on and it is a new song.
983// The work runs in the background; the panel draws a stand-in until the cover is ready.
984function wantCover($) {
985 if (!big || source !== "hum" || !hum || !hum.track) {
986 return;
987 }
988 const key = coverKey(hum.track);
989 if (cover.key === key) {
990 return;
991 }
992 cover = emptyCover(key);
993 void loadCover($, key, hum.track);
994}
995
996// Downloads the cover and makes its files with sips (see COVER_SCRIPT), then reads them.
997async function loadCover($, key, track) {
998 const home = await $.env.get("HOME").catch(() => undefined);
999 const urls = coverUrls(track);
1000 let found = null;
1001 if (home && urls.length > 0) {
1002 const root = `${home}/.claude/hush/art`;
1003 const dir = `${root}/${key}`;
1004 const run = await $.process
1005 .run(["/bin/sh", "-c", COVER_SCRIPT, "sh", dir, root, String(COVER_KEEP), ...urls], { timeoutMs: COVER_MS })
1006 .catch(() => ({ exitCode: 1 }));
1007 if (run.exitCode === 0) {
1008 found = await readCover($, dir);
1009 }
1010 }
1011 if (cover.key !== key) {
1012 return;
1013 }
1014 cover = found ? { ...emptyCover(key), ...found, state: "ready" } : emptyCover(key);
1015 $.ui.invalidate("ui.render");
1016}
1017
1018async function readCover($, dir) {
1019 try {
1020 const small = await $.fs.read(`${dir}/cells.bmp`, { as: "bytes" });
1021 const image = parseBmp(fromBase64(small.base64));
1022 if (!image) {
1023 return null;
1024 }
1025 const jpg = await $.fs.read(`${dir}/cover.jpg`, { as: "bytes" });
1026 const png = canImages ? await $.fs.read(`${dir}/cover.png`, { as: "bytes" }) : { base64: "" };
1027 const found = palette(image.rgba);
1028 // The pixels are kept, and cut to cells when drawn, since the cover's size follows the room.
1029 return { png: png.base64, jpg: jpg.base64, thumb: image, colors: found.colors, accent: found.accent };
1030 } catch {
1031 return null;
1032 }
1033}
1034
1035// The cover as cells at the size asked for, made once per size.
1036function coverCells(geo) {
1037 const key = `${geo.cover}x${geo.rows}`;
1038 if (cover.cellsKey !== key) {
1039 cover.cellsKey = key;
1040 const words = cover.thumb
1041 ? quadrantCells(cover.thumb.rgba, cover.thumb.width, cover.thumb.height, geo.cover, geo.rows)
1042 : placeholderCells(geo.cover, geo.rows, cover.colors);
1043 cover.cells = encodeCells(words);
1044 }
1045 return cover.cells;
1046}
1047
1048// The aurora moves while the terminal panel is on screen and the song plays: one Raster
1049// repainted about eight times a second with $.ui.blit, never the whole tree. It stops when the
1050// music is paused or stepped down, the panel is replaced, or the band has not been drawn for a while.
1051function auroraTime() {
1052 return (now() - EPOCH) / 1000;
1053}
1054
1055function syncAurora($) {
1056 const wanted = panelSite !== null && siteKey(panelSite) !== auroraBlocked && source === "hum" && isPlaying() && !isDucked();
1057 if (!wanted) {
1058 stopAurora();
1059 return;
1060 }
1061 if (!auroraTimer) {
1062 auroraTimer = $.clock.every(AURORA_MS, () => {
1063 void auroraFrame($);
1064 });
1065 }
1066}
1067
1068// A surface that keeps refusing the blits is left alone until the panel is drawn again from scratch.
1069function siteKey(site) {
1070 return `${site.requestId}:${site.columns}x${site.rows}`;
1071}
1072
1073function stopAurora() {
1074 if (auroraTimer) {
1075 auroraTimer.cancel();
1076 auroraTimer = null;
1077 }
1078 auroraMisses = 0;
1079}
1080
1081// The next lyric line is drawn the moment it starts, not at the next poll: when it is due within
1082// the next second and a bit, the tree is drawn again then.
1083function scheduleLine($, view) {
1084 stopLine();
1085 if (lastSurface === "desktop" && big) {
1086 return;
1087 }
1088 if (view.state !== "playing" || typeof view.nextIn !== "number" || view.nextIn > 1.4) {
1089 return;
1090 }
1091 lineTimer = $.clock.after(Math.max(30, Math.round(view.nextIn * 1000) + 30), () => {
1092 lineTimer = null;
1093 $.ui.invalidate("ui.render");
1094 });
1095}
1096
1097function stopLine() {
1098 if (lineTimer) {
1099 lineTimer.cancel();
1100 lineTimer = null;
1101 }
1102}
1103
1104async function auroraFrame($) {
1105 const site = panelSite;
1106 if (auroraBusy) {
1107 return;
1108 }
1109 if (!site || now() - site.at > AURORA_STALE_MS) {
1110 stopAurora();
1111 return;
1112 }
1113 auroraBusy = true;
1114 try {
1115 const cells = encodeCells(auroraCells(site.columns, site.rows, auroraTime(), cover.colors, 1));
1116 const result = await $.ui.blit({ requestId: site.requestId, key: "aurora", cells, columns: site.columns, rows: site.rows });
1117 auroraMisses = result && result.deny ? auroraMisses + 1 : 0;
1118 } catch {
1119 auroraMisses += 1;
1120 } finally {
1121 auroraBusy = false;
1122 }
1123 if (auroraMisses >= AURORA_MISSES) {
1124 auroraBlocked = siteKey(site);
1125 stopAurora();
1126 }
1127}
1128
1129// The terminal's name in the environment is only a hint: the engine decides whether it really
1130// draws pictures, and a blit is refused when it draws the alt instead. When that stays so, the
1131// cover is drawn as cell art. The first look comes after the terminal has had time to answer.
1132function probeImage($, requestId, png, key, attempt) {
1133 $.clock.after(attempt === 0 ? PROBE_MS : PROBE_MS * 2, async () => {
1134 if (cover.key !== key || panelSite === null) {
1135 return;
1136 }
1137 const result = await $.ui.blit({ requestId, key: "cover", source: { png } }).catch(() => undefined);
1138 if (!result || !result.deny || !result.deny.includes("alt")) {
1139 return;
1140 }
1141 if (attempt === 0) {
1142 probeImage($, requestId, png, key, 1);
1143 return;
1144 }
1145 imagesBlocked = true;
1146 $.ui.invalidate("ui.render");
1147 });
1148}
1149
1150// The panel is not drawn (band, survey or nothing playing): the aurora has nothing to paint.
1151function leavePanel($, e) {
1152 if (e.surface === "terminal") {
1153 panelSite = null;
1154 auroraBlocked = "";
1155 stopAurora();
1156 }
1157}
1158
1159function glowLevel(song) {
1160 if (isDucked()) {
1161 return 0.3;
1162 }
1163 return song.state === "paused" ? 0.45 : 1;
1164}
1165
1166// The colours the panel's words and bar take from the cover: the two strongest colours, lifted
1167// to read on the dark background (and let down when the music is quiet), and a deep one for the
1168// play button, dark enough for white words.
1169function look(quiet) {
1170 const fade = (rgb) => (quiet ? mix(rgb, PANEL_BG, 0.5).map(Math.round) : rgb.map(Math.round));
1171 return {
1172 from: fade(tone(cover.colors[0], 0.58, 0.5)),
1173 to: fade(tone(cover.colors[1], 0.68, 0.5)),
1174 pill: fade(pillColor(cover.colors[0])),
1175 };
1176}
1177
1178// Prev, play or pause, next, then the volume (when focused and there is room) and DJ Clawd.
1179// `room` is how many cells the row has. The play button is a pill in the cover's colour, the
1180// others plain words, so the main action reads at a glance. DJ Clawd stays in big mode, at the
1181// end of this row.
1182function controlsRow($, ui, song, columns, room, tint) {
1183 const { Box, Text, Button } = ui;
1184 const lit = isLit;
1185 const playing = song.state !== "paused";
1186 const parts = [
1187 Button({ key: "prev", label: "‹ prev", ...keyed("h"), plain: true, dimColor: !lit, onPress: () => void press($, "prev") }),
1188 Text({ children: " " }),
1189 Box({
1190 backgroundColor: hex(tint.pill),
1191 children: Button({
1192 key: "play",
1193 label: playing ? " pause " : " play ",
1194 ...keyed("p"),
1195 plain: true,
1196 autoFocus: true,
1197 onPress: () => void press($, "pause"),
1198 }),
1199 }),
1200 Text({ children: " " }),hooks/stage.mjs 259 lines1// The desktop stage: the player in the Claude app's Code tab, one composed picture. The cover
2// and its light on the left; the title, the lyric being sung, a progress bar and the time in
3// the middle; DJ Clawd at his booth on the right. The song's next minutes are written into the
4// picture (lyric lines fade in at their times, the bar glides, the time ticks), so it moves by
5// itself and the band is only redrawn when something else changes (the track, play or pause,
6// a question, the volume) or every two minutes to carry the timeline on. A redraw builds it
7// from the current position, and the loops take their phase from the clock, so a rebuilt
8// picture carries on exactly where the old one was.
9import { hex } from "./panel.mjs";
10
11export const STAGE_HEIGHT = 196;
12export const STAGE_LIMIT = 126000;
13const W = 760;
14const BG = "#131315";
15const BONE = "#f6f2ec";
16const BEAT = 0.5; // a steady groove: the player's audio is sealed inside YouTube's frame
17
18const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
19const fit = (s, n) => (String(s).length > n ? `${String(s).slice(0, n - 1).trimEnd()}…` : String(s));
20const n2 = (v) => Number(v.toFixed(2));
21
22// The engine refuses an Svg over 131072 characters: a picture that comes out too big is built
23// again with a shorter window of the song ahead, and at the last without the cover.
24export function stageSvg(args) {
25 for (const ahead of [150, 75, 30, 0]) {
26 const svg = buildStage({ ...args, ahead });
27 if (svg.length <= STAGE_LIMIT) return svg;
28 }
29 return buildStage({ ...args, ahead: 0, jpeg: "" });
30}
31
32function buildStage({ title, artist, album = "", upNext = "", lyrics = [], pos = 0, dur = 0, playing, ducked, colors, accent, jpeg, clawd = true, clock = 0, ahead = 150 }) {
33 const [c0, c1, c2] = colors.map(hex);
34 const ac = hex(accent);
35 const live = playing && !ducked;
36 const dim = !live;
37 const H = STAGE_HEIGHT;
38 const phase = (d, shift = 0) => `begin="-${n2((clock + shift) % d)}s"`;
39 const drift = (attr, values, d) =>
40 live ? `<animate attributeName="${attr}" values="${values}" dur="${d}s" ${phase(d)} repeatCount="indefinite" calcMode="spline" keyTimes="0;0.5;1" keySplines="0.45 0 0.55 1;0.45 0 0.55 1"/>` : "";
41
42 const S = 150, CX = 24, CY = (H - S) / 2;
43 const art = jpeg ? `<image id="art" href="data:image/jpeg;base64,${jpeg}" width="100" height="100" preserveAspectRatio="xMidYMid slice"/>` : `<rect id="art" width="100" height="100" fill="${c0}"/>`;
44 const useArt = (x, y, size) => `<use href="#art" transform="translate(${x} ${y}) scale(${n2(size / 100)})"/>`;
45
46 const TX = 200;
47 const label = ducked ? "TURNED DOWN" : playing ? "NOW PLAYING" : "PAUSED";
48 const eq = live
49 ? [0, 5, 10]
50 .map((dx, i) => {
51 const lo = [3, 8, 4][i], hi = [10, 3, 11][i], d = [0.86, 1.04, 0.72][i];
52 return `<rect x="${TX + 104 + dx}" width="2.5" rx="1.25" fill="${ac}"><animate attributeName="height" values="${lo};${hi};${lo}" dur="${d}s" ${phase(d)} repeatCount="indefinite"/><animate attributeName="y" values="${46 - lo};${46 - hi};${46 - lo}" dur="${d}s" ${phase(d)} repeatCount="indefinite"/></rect>`;
53 })
54 .join("")
55 : "";
56 const sub = album && album !== title ? `<tspan fill-opacity="0.5"> · ${esc(fit(album, 24))}</tspan>` : "";
57 const words = [
58 `<text x="${TX}" y="46" font-size="10" font-weight="700" letter-spacing="2.3" fill="${ac}" fill-opacity="${dim ? 0.6 : 0.95}">${label}</text>${eq}`,
59 `<text x="${TX}" y="78" font-size="28" font-weight="760" letter-spacing="-0.7" fill="${BONE}" fill-opacity="${dim ? 0.75 : 1}">${esc(fit(title, 22))}</text>`,
60 `<text x="${TX}" y="101" font-size="14.5" fill="${BONE}" fill-opacity="${dim ? 0.48 : 0.7}">${esc(fit(artist, 30))}${sub}</text>`,
61 ].join("");
62
63
64 // the song: the lyric being sung over the next one, each shown for its own span; a bar that
65 // glides to the end; the elapsed time ticking second by second
66 const TW = 300;
67 const clockText = (sec) => `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, "0")}`;
68 let lines = "";
69 const timed = lyrics.filter((l) => l && typeof l.t === "number").sort((a, b) => a.t - b.t);
70 if (timed.length) {
71 for (let i = 0; i < timed.length; i++) {
72 const start = timed[i].t, end = i + 1 < timed.length ? timed[i + 1].t : dur || start + 8;
73 if (end <= pos) continue;
74 if (start - pos > ahead) break;
75 const b0 = n2(Math.max(0, start - pos)), b1 = n2(end - pos);
76 const shown = start <= pos;
77 const now = timed[i].text?.trim() ? timed[i].text : "\u266a";
78 const next = i + 1 < timed.length ? timed[i + 1].text || "" : "";
79 const line = (y, size, weight, fill, op, text) => {
80 const enter = shown ? "" : `<animate attributeName="opacity" from="0" to="${op}" begin="${b0}s" dur="0.28s" fill="freeze"/><animate attributeName="y" from="${y + 5}" to="${y}" begin="${b0}s" dur="0.32s" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines="0.2 0.7 0.3 1"/>`;
81 const leave = live ? `<set attributeName="opacity" to="0" begin="${b1}s"/>` : "";
82 return `<text x="${TX}" y="${y}" font-size="${size}" font-weight="${weight}" fill="${fill}" opacity="${shown ? op : 0}">${esc(fit(text, size > 15 ? 34 : 42))}${live ? enter : ""}${leave}</text>`;
83 };
84 lines += line(126, 16.5, 650, ac, dim ? 0.6 : 1, now);
85 if (next) lines += line(147, 14, 400, BONE, dim ? 0.3 : 0.42, next);
86 if (!live) break;
87 }
88 } else {
89 lines =
90 `<text x="${TX}" y="126" font-size="15.5" font-style="italic" fill="${BONE}" fill-opacity="0.72">${esc(fit(album || "No synced lyrics for this one", 36))}</text>` +
91 (upNext ? `<text x="${TX}" y="147" font-size="13.5" fill="${BONE}" fill-opacity="0.42">up next \u00b7 ${esc(fit(upNext, 36))}</text>` : "");
92 }
93 const frac = dur > 0 ? Math.min(1, pos / dur) : 0;
94 const w0 = n2(TW * frac);
95 const glide = live && dur > pos ? `<animate attributeName="width" from="${w0}" to="${TW}" dur="${n2(dur - pos)}s" fill="freeze"/>` : "";
96 const knob = live && dur > pos ? `<animate attributeName="cx" from="${TX + w0}" to="${TX + TW}" dur="${n2(dur - pos)}s" fill="freeze"/>` : "";
97 const bar =
98 `<rect x="${TX}" y="164" width="${TW}" height="3" rx="1.5" fill="${BONE}" fill-opacity="0.12"/>` +
99 `<rect x="${TX}" y="164" width="${w0}" height="3" rx="1.5" fill="url(#prog)">${glide}</rect>` +
100 `<circle cx="${TX + w0}" cy="165.5" r="4.5" fill="${BONE}" fill-opacity="${dim ? 0.6 : 0.95}">${knob}</circle>`;
101 let ticks = "";
102 if (live && dur > pos) {
103 const first = Math.floor(pos);
104 for (let k = first; k < Math.min(dur, first + ahead + 30); k++) {
105 const b0 = n2(Math.max(0, k - pos)), b1 = n2(k + 1 - pos);
106 ticks += `<tspan x="${TX}" visibility="${k === first ? "visible" : "hidden"}">${clockText(k)}${k === first ? "" : `<set attributeName="visibility" to="visible" begin="${b0}s"/>`}<set attributeName="visibility" to="hidden" begin="${b1}s"/></tspan>`;
107 }
108 } else {
109 ticks = `<tspan x="${TX}">${clockText(pos)}</tspan>`;
110 }
111 const times =
112 `<text y="186" font-size="11" fill="${BONE}" fill-opacity="0.62">${ticks}</text>` +
113 (dur ? `<text x="${TX + TW}" y="186" text-anchor="end" font-size="11" fill="${BONE}" fill-opacity="0.38">${clockText(dur)}</text>` : "");
114
115 return [
116 `<svg xmlns="http://www.w3.org/2000/svg" width="4000" height="${H}" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" font-family="-apple-system, 'SF Pro Display', 'Helvetica Neue', Arial, sans-serif">`,
117 `<defs>${art}`,
118 `<clipPath id="cv"><rect x="${CX}" y="${CY}" width="${S}" height="${S}" rx="14"/></clipPath>`,
119 `<clipPath id="col"><rect x="${TX - 4}" y="0" width="330" height="${H}"/></clipPath>`,
120 `<filter id="haze" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="44"/></filter>`,
121 `<filter id="lift" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="15"/></filter>`,
122 `<filter id="soft" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="14"/></filter>`,
123 `<linearGradient id="prog" x1="0" x2="1"><stop offset="0" stop-color="${c1}"/><stop offset="1" stop-color="${ac}"/></linearGradient>`,
124 `<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity="0.16"/><stop offset="0.5" stop-color="#fff" stop-opacity="0"/></linearGradient>`,
125 `<linearGradient id="cone" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${c1}" stop-opacity="0.34"/><stop offset="1" stop-color="${c1}" stop-opacity="0"/></linearGradient>`,
126 `<radialGradient id="vinyl"><stop offset="0.36" stop-color="#121214"/><stop offset="0.37" stop-color="#202024"/><stop offset="1" stop-color="#0c0c0e"/></radialGradient>`,
127 `</defs>`,
128 `<rect x="-2000" y="-200" width="${W + 4000}" height="${H + 400}" fill="${BG}"/>`,
129 `<g opacity="${dim ? 0.3 : 0.55}" filter="url(#haze)">${useArt(-60, -200, 540)}</g>`,
130 `<g filter="url(#haze)" opacity="${dim ? 0.14 : 0.3}">`,
131 `<ellipse cx="120" cy="86" rx="170" ry="110" fill="${c0}">${drift("cx", "120;220;120", 19)}</ellipse>`,
132 `<ellipse cx="360" cy="10" rx="180" ry="70" fill="${c1}">${drift("cx", "360;250;360", 27)}</ellipse>`,
133 `<ellipse cx="660" cy="160" rx="190" ry="80" fill="${c2}">${drift("cy", "160;120;160", 23)}</ellipse>`,
134 `</g>`,
135 `<rect x="${CX + 10}" y="${CY + 16}" width="${S - 20}" height="${S - 10}" rx="16" fill="${c0}" filter="url(#lift)" fill-opacity="${dim ? 0.3 : 0.7}"/>`,
136 `<g clip-path="url(#cv)">${useArt(CX, CY, S)}<rect x="${CX}" y="${CY}" width="${S}" height="${S}" fill="url(#sheen)"/></g>`,
137 `<rect x="${CX + 0.5}" y="${CY + 0.5}" width="${S - 1}" height="${S - 1}" rx="13.5" fill="none" stroke="#fff" stroke-opacity="0.12"/>`,
138 `<g clip-path="url(#col)">${words}${lines}${bar}</g>`,
139 times,
140 clawd ? booth({ x: 528, H, live, playing, ducked, ac, c1, phase, drift, useArt, clock }) : "",
141 `</svg>`,
142 ].join("");
143}
144
145// ---------------- DJ Clawd
146
147// His routine over 16 beats, in half beats: which pose each arm holds. d = hands on the decks,
148// s = working the record, e = holding an ear cup, u = a hand raised. Looks follow the work.
149const ROUTINE = {
150 left: "dddddddd" + "sdsdsdsd" + "dddddddd" + "eeeedddd",
151 right: "dddddddd" + "dddddddd" + "sdsdsdsd" + "dddduudd",
152};
153const LOOK = "00000000" + "LLLLLLLL" + "RRRRRRRR" + "00000000"; // where his eyes are turned
154const BLINKS = [13, 27]; // half beats at which he blinks
155
156function booth({ x, H, live, playing, ducked, ac, c1, phase, drift, useArt, clock }) {
157 const u = 6.5;
158 const O = "#d97757", E = "#1b1410", P = "#2a2a2e";
159 const top = H - 56; // the desk's top edge
160 const bw = 216;
161 const ox = bw / 2 - 6 * u, oy = top - 5 * u; // where Clawd stands
162 const cycle = BEAT * 16, slots = 32;
163 const keyTimes = Array.from({ length: slots }, (_, i) => n2(i / slots)).join(";");
164 // one switch per half beat, so poses change like frames of a sprite
165 const frames = (seq, on) =>
166 live
167 ? `<animate attributeName="opacity" values="${[...seq].map((c) => (c === on ? 1 : 0)).join(";")}" keyTimes="${keyTimes}" calcMode="discrete" dur="${cycle}s" ${phase(cycle)} repeatCount="indefinite"/>`
168 : "";
169 const px = (x0, y0, w, h, fill = O) => `<rect x="${n2(x0 * u)}" y="${n2(y0 * u)}" width="${n2(w * u)}" height="${n2(h * u)}" fill="${fill}"/>`;
170 // one arm in each pose, drawn for the left side; the right is its mirror
171 const POSES = {
172 d: px(-2.4, 2, 2.4, 1) + px(-3.4, 2, 1, 3.6) + px(-4, 5.4, 2.2, 1.1),
173 s: px(-2.4, 2, 2.4, 1) + px(-4, 2, 1, 3.6) + px(-4.8, 5.4, 2.2, 1.1),
174 e: px(-1.2, 2.2, 1.2, 1) + px(-1.9, 0.6, 1, 2.6) + px(-2.1, 0.2, 1.3, 1.1),
175 u: px(-1.2, 2, 1.2, 1) + px(-2, -2.4, 1, 4.4) + px(-2.3, -3.3, 1.6, 1.1),
176 };
177 const restPose = ducked ? { left: "d", right: "e" } : { left: "d", right: "d" };
178 const arm = (side) => {
179 const seq = ROUTINE[side];
180 const mirror = side === "right" ? ` transform="matrix(-1 0 0 1 ${12 * u} 0)"` : "";
181 const poses = Object.entries(POSES)
182 .map(([k, shape]) => {
183 const shown = live ? seq[0] === k : restPose[side] === k;
184 return `<g opacity="${shown ? 1 : 0}">${shape}${frames(seq, k)}</g>`;
185 })
186 .join("");
187 return `<g${mirror}>${poses}</g>`;
188 };
189 const body = [[1, 0], [11, 0], [11, 2], [12, 2], [12, 4], [11, 4], [11, 6], [1, 6], [1, 4], [0, 4], [0, 2], [1, 2]]
190 .map(([a, b], i) => `${i ? "L" : "M"}${a * u} ${b * u}`)
191 .join(" ");
192 // eyes: they glance at the deck being worked, blink now and then, and look to the prompt when asked
193 const lookX = ducked ? -0.6 * u : 0;
194 const glance = live
195 ? `<animateTransform attributeName="transform" type="translate" values="${[...LOOK].map((c) => (c === "L" ? `${-0.5 * u} 0` : c === "R" ? `${0.5 * u} 0` : "0 0")).join(";")}" keyTimes="${keyTimes}" calcMode="discrete" dur="${cycle}s" ${phase(cycle)} repeatCount="indefinite"/>`
196 : "";
197 const blink = live
198 ? `<animate attributeName="height" values="${Array.from({ length: slots }, (_, i) => (BLINKS.includes(i) ? n2(u * 0.2) : u)).join(";")}" keyTimes="${keyTimes}" calcMode="discrete" dur="${cycle}s" ${phase(cycle)} repeatCount="indefinite"/>`
199 : "";
200 const eye = (ex) => `<rect x="${ex * u + lookX}" y="${u}" width="${u}" height="${playing || ducked ? u : n2(u * 0.35)}" fill="${E}">${blink}</rect>`;
201 const cupLifted = ducked;
202 const clawd = [
203 `<g transform="translate(${ox} ${oy})" shape-rendering="crispEdges">`,
204 `<g>`,
205 live ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 ${n2(u * 0.35)};0 0" dur="${BEAT}s" ${phase(BEAT)} repeatCount="indefinite" calcMode="spline" keyTimes="0;0.5;1" keySplines="0.4 0 0.6 1;0.4 0 0.6 1"/>` : "",
206 `<path d="${body} Z" fill="${O}"/>`,
207 `<g>${glance}${eye(3)}${eye(8)}</g>`,
208 `<path d="M ${-0.2 * u} ${2 * u} Q ${6 * u} ${-5 * u} ${12.2 * u} ${2 * u}" fill="none" stroke="${P}" stroke-width="${u * 0.9}" stroke-linecap="round"/>`,
209 px(-1, 1, 2, 3, P),
210 `<g${cupLifted ? ` transform="translate(${u * 1.6} ${-u * 1.8}) rotate(26 ${12 * u} ${2 * u})"` : ""}>${px(11, 1, 2, 3, P)}</g>`,
211 `</g>`,
212 `</g>`,
213 ].join("");
214 const arms = `<g transform="translate(${ox} ${oy})" shape-rendering="crispEdges"><g>${live ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 ${n2(u * 0.35)};0 0" dur="${BEAT}s" ${phase(BEAT)} repeatCount="indefinite" calcMode="spline" keyTimes="0;0.5;1" keySplines="0.4 0 0.6 1;0.4 0 0.6 1"/>` : ""}${arm("left")}${arm("right")}</g></g>`;
215
216 const spin = (d) => (live ? `<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="${d}s" ${phase(d)} repeatCount="indefinite"/>` : "");
217 const deck = (cx) => {
218 const cy = top + 9;
219 return [
220 `<g transform="translate(${cx} ${cy}) scale(1 0.34)">`,
221 `<circle r="40" fill="#09090b"/><circle r="37" fill="url(#vinyl)"/>`,
222 [30, 25, 20].map((r) => `<circle r="${r}" fill="none" stroke="#2c2c31" stroke-width="0.8"/>`).join(""),
223 `<g>${spin(1.8)}`,
224 `<clipPath id="lab${cx}"><circle r="12.5"/></clipPath><g clip-path="url(#lab${cx})">${useArt(-12.5, -12.5, 25)}</g>`,
225 `<path d="M -34 -6 A 35 35 0 0 1 -6 -34" stroke="#fff" stroke-opacity="0.16" stroke-width="2" fill="none"/>`,
226 `</g><circle r="2" fill="#ccc"/></g>`,
227 `<g transform="translate(${cx + 32} ${cy - 10}) rotate(${playing ? 28 : 8})"><rect x="-1" y="0" width="2" height="22" rx="1" fill="#8a8a92"/><rect x="-2.5" y="20" width="5" height="4" rx="1" fill="#b9b9c2"/></g>`,
228 `<circle cx="${cx + 32}" cy="${cy - 10}" r="3" fill="#55555c"/>`,
229 ].join("");
230 };
231 const meter = (mx, d0) => {
232 let s = "";
233 for (let k = 0; k < 8; k++) {
234 const fill = k >= 6 ? ac : k >= 4 ? c1 : "#7fd1a8";
235 const op = live ? (k < 3 ? 0.9 : 0.12) : k < 1 ? 0.5 : 0.1;
236 const flick = live && k >= 3 ? `<animate attributeName="fill-opacity" values="0.12;0.9;0.12;0.12" keyTimes="0;${n2(0.12 + k * 0.02)};${n2(0.4 + k * 0.03)};1" dur="${n2(BEAT * (k % 2 ? 1 : 2))}s" begin="-${n2((clock + d0 + k * 0.07) % BEAT)}s" repeatCount="indefinite"/>` : "";
237 s += `<rect x="${mx}" y="${top + 41 - k * 4.2}" width="7" height="3" rx="1" fill="${fill}" fill-opacity="${op}">${flick}</rect>`;
238 }
239 return s;
240 };
241 return [
242 `<g transform="translate(${x} 0)">`,
243 `<path d="M ${bw / 2 - 30} -10 L ${bw / 2 + 30} -10 L ${bw + 10} ${top + 10} L -10 ${top + 10} Z" fill="url(#cone)" filter="url(#soft)" opacity="${live ? 0.9 : 0.35}">${drift("opacity", "0.7;1;0.7", BEAT * 8)}</path>`,
244 clawd,
245 `<rect x="0" y="${top}" width="${bw}" height="${H - top + 20}" rx="10" fill="#1b1b1f"/>`,
246 `<rect x="1" y="${top}" width="${bw - 2}" height="1.5" fill="${ac}" fill-opacity="${live ? 0.55 : 0.2}"/>`,
247 `<rect x="0" y="${top + 26}" width="${bw}" height="${H - top}" fill="#151518"/>`,
248 `<text x="20" y="${top + 46}" font-family="Georgia, 'Times New Roman', serif" font-style="italic" font-size="15" fill="${BONE}" fill-opacity="0.26">hum</text>`,
249 deck(46),
250 deck(bw - 46),
251 arms,
252 meter(bw / 2 - 12, 0.11),
253 meter(bw / 2 + 5, 0.27),
254 `<rect x="${bw / 2 - 16}" y="${top + 49}" width="32" height="2" rx="1" fill="#2e2e33"/>`,
255 `<rect y="${top + 46}" width="7" height="7" rx="1.5" fill="#c9c9d1" x="${bw / 2 - 12}">${live ? `<animate attributeName="x" values="${bw / 2 - 12};${bw / 2 + 5};${bw / 2 - 12}" dur="${BEAT * 32}s" ${phase(BEAT * 32)} repeatCount="indefinite" calcMode="spline" keyTimes="0;0.5;1" keySplines="0.45 0 0.55 1;0.45 0 0.55 1"/>` : ""}</rect>`,
256 `</g>`,
257 ].join("");
258}
259hooks/logic.mjs 198 lines1// Pure logic for Hush: no host calls, so tests can run it directly.
2//
3// Hush plays two kinds of audio. Radio goes through the native helper. Songs go through
4// hum (https://github.com/anzal1/hum), which plays through YouTube's own embed in a visible
5// window. This file decides what a /music command means for each, and how hum's state is
6// turned into the line (or the big panel) shown above the prompt.
7
8export const LYRIC_LEAD = 0.15;
9export const VOLUME_STEP = 10;
10
11export function clamp(value, low, high) {
12 return Math.max(low, Math.min(high, value));
13}
14
15// Where the song is right now: hum's report plus the time since it was made, while it plays.
16export function livePosition(state, now) {
17 if (!state || typeof state.position !== "number") {
18 return 0;
19 }
20 let seconds = state.position;
21 if (state.playing && typeof state.at === "number") {
22 seconds += Math.max(0, now - state.at) / 1000;
23 }
24 if (state.duration > 0) {
25 seconds = Math.min(seconds, state.duration);
26 }
27 return Math.max(0, seconds);
28}
29
30// The last synced line that has started by `seconds`, a little early so the eye is not behind the voice.
31export function lyricLine(lyrics, seconds) {
32 if (!Array.isArray(lyrics)) {
33 return "";
34 }
35 const cutoff = seconds + LYRIC_LEAD;
36 let best = null;
37 for (const entry of lyrics) {
38 if (entry && typeof entry.t === "number" && entry.t <= cutoff && (best === null || entry.t >= best.t)) {
39 best = entry;
40 }
41 }
42 return best && typeof best.text === "string" ? best.text.trim() : "";
43}
44
45// The line being sung, the one after it, and how many seconds until that one starts (null
46// when there is none). Blank lines in the file are interludes: `line` is "" for one, and the
47// next line skips them. Before the first line, `line` is "" and `next` is the first line.
48export function lyricWindow(lyrics, seconds) {
49 const none = { line: "", next: "", nextIn: null };
50 if (!Array.isArray(lyrics)) {
51 return none;
52 }
53 const cutoff = seconds + LYRIC_LEAD;
54 let best = null;
55 for (const entry of lyrics) {
56 if (entry && typeof entry.t === "number" && entry.t <= cutoff && (best === null || entry.t >= best.t)) {
57 best = entry;
58 }
59 }
60 let next = null;
61 for (const entry of lyrics) {
62 if (entry && typeof entry.t === "number" && typeof entry.text === "string" && entry.text.trim() !== "" && entry.t > cutoff && (next === null || entry.t < next.t)) {
63 next = entry;
64 }
65 }
66 return {
67 line: best && typeof best.text === "string" ? best.text.trim() : "",
68 next: next ? next.text.trim() : "",
69 nextIn: next ? next.t - cutoff : null,
70 };
71}
72
73// What the band draws for hum, or null when no hum window has a track.
74export function humView(state, now) {
75 if (!state || !(state.connected > 0) || !state.track) {
76 return null;
77 }
78 const pos = livePosition(state, now);
79 const artist = state.track.artist || "";
80 const title = state.track.title || artist || "hum";
81 const lyrics = Array.isArray(state.lyrics) ? state.lyrics : [];
82 const window = lyricWindow(lyrics, pos);
83 const queue = Array.isArray(state.upNext) ? state.upNext.find((x) => typeof x === "string" && x.trim() !== "") : "";
84 return {
85 state: state.playing ? "playing" : "paused",
86 title,
87 artist,
88 name: artist && artist !== title ? `${title} · ${artist}` : title,
89 id: typeof state.track.id === "string" ? state.track.id : "",
90 art: typeof state.track.art === "string" ? state.track.art : "",
91 line: window.line,
92 next: window.next,
93 nextIn: window.nextIn,
94 hasLyrics: lyrics.length > 0,
95 album: typeof state.track.album === "string" && state.track.album !== title ? state.track.album.trim() : "",
96 upNext: typeof queue === "string" ? queue.trim() : "",
97 pos,
98 dur: state.duration > 0 ? state.duration : 0,
99 volume: Number.isFinite(state.volume) ? state.volume : 0,
100 ducked: Boolean(state.ducked),
101 };
102}
103
104// What the person typed after /music, as an intent. Stations are matched by id.
105export function parseArgs(args, stations) {
106 const [first = "", ...rest] = args.split(/\s+/).filter(Boolean);
107 const word = first.toLowerCase();
108 if (word === "") {
109 return { kind: "bare" };
110 }
111 const index = stations.findIndex((id) => id === word);
112 if (index >= 0 && rest.length === 0) {
113 return { kind: "station", index };
114 }
115 if (word === "radio" && rest.length === 0) {
116 return { kind: "radio" };
117 }
118 // Only a bare word is a command, so "next level" and "stop making sense" are song searches.
119 if (word === "next" && rest.length === 0) {
120 return { kind: "next" };
121 }
122 if ((word === "prev" || word === "previous") && rest.length === 0) {
123 return { kind: "prev" };
124 }
125 if ((word === "stop" || word === "off") && rest.length === 0) {
126 return { kind: "stop" };
127 }
128 if (word === "vol" || word === "volume") {
129 const n = Number.parseInt(rest[0] ?? "", 10);
130 return { kind: "vol", value: Number.isNaN(n) ? null : n };
131 }
132 if (word === "clawd" && rest.length === 0) {
133 return { kind: "clawd" };
134 }
135 if (word === "big" && rest.length === 0) {
136 return { kind: "big" };
137 }
138 if ((word === "pause" || word === "play") && rest.length === 0) {
139 return { kind: word };
140 }
141 const query = (word === "play" || word === "song" ? rest : [first, ...rest]).join(" ");
142 return query === "" ? { kind: "bare" } : { kind: "search", query };
143}
144
145// What an intent does, given where the sound comes from now. ctx is
146// { source: "radio" | "hum", humLive, radioActive, radioVolume, humVolume, stationIndex }.
147// Returns { target, action, ... }. A hum plan carries `cmd`, the body to POST to hum.
148// `source` is the source to switch to once the plan has worked, when it changes.
149export function route(intent, ctx) {
150 const onHum = ctx.source === "hum" && ctx.humLive;
151 const hum = (action, cmd, extra = {}) => ({ target: "hum", action, cmd, ...extra });
152 const radio = (action, extra = {}) => ({ target: "radio", action, ...extra });
153 switch (intent.kind) {
154 case "search":
155 return hum("play", { cmd: "play", query: intent.query }, { source: "hum" });
156 case "station":
157 return radio("station", { index: intent.index, source: "radio" });
158 case "radio":
159 return radio("station", { index: ctx.stationIndex, source: "radio" });
160 case "bare":
161 case "pause":
162 case "play":
163 if (onHum) {
164 const cmd = intent.kind === "pause" ? "pause" : intent.kind === "play" ? "resume" : "toggle";
165 return hum(intent.kind, { cmd });
166 }
167 return ctx.radioActive ? radio("toggle") : radio("station", { index: ctx.stationIndex, source: "radio" });
168 case "toggle":
169 return onHum ? hum("toggle", { cmd: "toggle" }) : radio("toggle");
170 case "next":
171 return onHum ? hum("next", { cmd: "next" }) : radio("next");
172 case "prev":
173 return onHum ? hum("prev", { cmd: "prev" }) : radio("prev");
174 case "stop":
175 return onHum ? hum("stop", { cmd: "pause" }, { source: "radio" }) : radio("stop");
176 case "vol": {
177 const current = onHum ? ctx.humVolume : ctx.radioVolume;
178 let level = null;
179 if (typeof intent.value === "number") {
180 level = clamp(intent.value, 0, 100);
181 } else if (typeof intent.delta === "number") {
182 level = clamp(current + intent.delta, 0, 100);
183 }
184 return onHum ? hum("volume", level === null ? null : { cmd: "volume", level }, { level }) : radio("volume", { level });
185 }
186 default:
187 return { target: "local", action: intent.kind };
188 }
189}
190
191// The hum command that ducks or restores, or null when nothing needs sending.
192export function duckCommand(wantDucked, alreadyDucked) {
193 if (wantDucked === alreadyDucked) {
194 return null;
195 }
196 return { cmd: wantDucked ? "duck" : "unduck" };
197}
198hooks/panel.mjs 965 lines1// Pure parts of Hush's big panel: no host calls, so tests can run them directly.
2//
3// The panel is the richer view above the prompt while a song plays through hum: the cover,
4// an aurora in the cover's colours, the title, artist and lyric lines, progress and buttons.
5// This file reads the cover's pixels (a small BMP that /usr/bin/sips wrote), picks the
6// colours, makes the cell art for the terminal and the SVG for the desktop, and decides
7// which layout fits.
8//
9// The terminal art is drawn in quadrant blocks: every cell carries 2x2 pixels in two colours,
10// so a cover is twice as sharp across as half blocks give, and the aurora is dithered across
11// the same sub-pixels so a glow reads as light and not as steps.
12
13export const BACKGROUND = [8, 7, 11];
14export const BACKGROUND_HEX = "#08070b";
15export const BONE = "#f6f2ec";
16export const BONE_SOFT = "#b9b1a6";
17export const BONE_DIM = "#8a8379";
18export const TRACK = [38, 35, 46];
19
20// Claude Code paints a Raster's colours in 16 levels a channel (the nearest multiple of 17),
21// whatever is sent, so the cell art is made on that grid. The panel's own background is the
22// grid colour nearest hum's #08070b, so that the aurora's dark cells and the card around them
23// are one colour and no box shows.
24export const GRID = 17;
25export const PANEL_BG = [17, 17, 17];
26export const PANEL_BG_HEX = "#111111";
27
28// hum's own warm colours, used until a cover has been read or when there is none.
29export const FALLBACK_COLORS = [
30 [196, 112, 78],
31 [140, 110, 214],
32 [92, 142, 176],
33 [14, 11, 18],
34];
35export const FALLBACK_ACCENT = [235, 160, 128];
36
37export const PANEL_MIN_COLUMNS = 60;
38export const PANEL_MAX_COLUMNS = 120;
39export const PANEL_ROWS = 8;
40export const PANEL_ROWS_MAX = 10;
41export const PANEL_ROOM = 2;
42export const COVER_COLUMNS = 16;
43export const COVER_ROWS = 8;
44export const RIBBON_ROWS = 2;
45export const PANEL_GAP = 2;
46export const PANEL_PAD = 1;
47export const SVG_LIMIT = 131072;
48export const SVG_WIDTH = 560;
49export const SVG_HEIGHT = 148;
50export const UPPER_HALF = 0x2580;
51export const EIGHTHS = [" ", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589", "\u2588"];
52// Quadrant glyphs by mask: 1 upper left, 2 upper right, 4 lower left, 8 lower right.
53export const QUADRANTS = [" ", "\u2598", "\u259d", "\u2580", "\u2596", "\u258c", "\u259e", "\u259b", "\u2597", "\u259a", "\u2590", "\u259c", "\u2584", "\u2599", "\u259f", "\u2588"];
54
55export function fit(text, cells) {
56 if (cells <= 0) {
57 return "";
58 }
59 return text.length <= cells ? text : `${text.slice(0, Math.max(0, cells - 1))}…`;
60}
61
62export function clock(total) {
63 const seconds = Math.floor(total);
64 const m = Math.floor(seconds / 60);
65 const s = String(seconds % 60).padStart(2, "0");
66 return `${m}:${s}`;
67}
68
69// ---------- bytes ----------
70
71const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
72
73// Base64 by hand: the module's environment is not promised the newer Uint8Array helpers.
74export function toBase64(bytes) {
75 let out = "";
76 for (let i = 0; i < bytes.length; i += 3) {
77 const a = bytes[i];
78 const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
79 const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
80 out += B64[a >> 2] + B64[((a & 3) << 4) | (b >> 4)];
81 out += i + 1 < bytes.length ? B64[((b & 15) << 2) | (c >> 6)] : "=";
82 out += i + 2 < bytes.length ? B64[c & 63] : "=";
83 }
84 return out;
85}
86
87export function fromBase64(text) {
88 const clean = String(text).replace(/[^A-Za-z0-9+/]/g, "");
89 const out = new Uint8Array(Math.floor((clean.length * 3) / 4));
90 let n = 0;
91 for (let i = 0; i < clean.length; i += 4) {
92 const a = B64.indexOf(clean[i]);
93 const b = B64.indexOf(clean[i + 1]);
94 const c = i + 2 < clean.length ? B64.indexOf(clean[i + 2]) : -1;
95 const d = i + 3 < clean.length ? B64.indexOf(clean[i + 3]) : -1;
96 out[n++] = (a << 2) | (b >> 4);
97 if (c >= 0) {
98 out[n++] = ((b & 15) << 4) | (c >> 2);
99 }
100 if (d >= 0) {
101 out[n++] = ((c & 3) << 6) | d;
102 }
103 }
104 return out.slice(0, n);
105}
106
107// ---------- BMP ----------
108
109// Reads an uncompressed BMP (24 or 32 bits a pixel, rows bottom up or top down, as sips
110// writes them) into { width, height, rgba }. Returns null for anything else.
111export function parseBmp(bytes) {
112 if (!bytes || bytes.length < 54 || bytes[0] !== 0x42 || bytes[1] !== 0x4d) {
113 return null;
114 }
115 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
116 const offset = view.getUint32(10, true);
117 const dib = view.getUint32(14, true);
118 if (dib < 40) {
119 return null;
120 }
121 const width = view.getInt32(18, true);
122 const rawHeight = view.getInt32(22, true);
123 const bits = view.getUint16(28, true);
124 const compression = view.getUint32(30, true);
125 const height = Math.abs(rawHeight);
126 const topDown = rawHeight < 0;
127 const plain = compression === 0 || (compression === 3 && bits === 32);
128 if (!plain || (bits !== 24 && bits !== 32) || width < 1 || height < 1 || width > 1024 || height > 1024) {
129 return null;
130 }
131 const stride = Math.floor((bits * width + 31) / 32) * 4;
132 if (offset + stride * height > bytes.length) {
133 return null;
134 }
135 const step = bits / 8;
136 const rgba = new Uint8Array(width * height * 4);
137 for (let y = 0; y < height; y += 1) {
138 const row = offset + (topDown ? y : height - 1 - y) * stride;
139 for (let x = 0; x < width; x += 1) {
140 const at = row + x * step;
141 const to = (y * width + x) * 4;
142 rgba[to] = bytes[at + 2];
143 rgba[to + 1] = bytes[at + 1];
144 rgba[to + 2] = bytes[at];
145 rgba[to + 3] = 255;
146 }
147 }
148 return { width, height, rgba };
149}
150
151// ---------- colour ----------
152
153function rgbToHsl([r, g, b]) {
154 r /= 255;
155 g /= 255;
156 b /= 255;
157 const max = Math.max(r, g, b);
158 const min = Math.min(r, g, b);
159 const l = (max + min) / 2;
160 if (max === min) {
161 return [0, 0, l];
162 }
163 const d = max - min;
164 const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
165 let h;
166 if (max === r) {
167 h = (g - b) / d + (g < b ? 6 : 0);
168 } else if (max === g) {
169 h = (b - r) / d + 2;
170 } else {
171 h = (r - g) / d + 4;
172 }
173 return [h / 6, s, l];
174}
175
176function hslToRgb([h, s, l]) {
177 if (!s) {
178 return [l, l, l].map((v) => Math.round(v * 255));
179 }
180 const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
181 const p = 2 * l - q;
182 const f = (t) => {
183 t = (t + 1) % 1;
184 if (t < 1 / 6) return p + (q - p) * 6 * t;
185 if (t < 1 / 2) return q;
186 if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
187 return p;
188 };
189 return [f(h + 1 / 3), f(h), f(h - 1 / 3)].map((v) => Math.round(v * 255));
190}
191
192function distance(a, b) {
193 return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
194}
195
196// A few characterful colours from a cover, in the spirit of hum's public/palette.js: pixels
197// are bucketed, saturated mid tones score highest, and near duplicates are dropped. Returns
198// { colors: four glow colours (the last a deep shade), accent: a light one for text }.
199export function palette(rgba) {
200 const buckets = new Map();
201 for (let i = 0; i + 3 < rgba.length; i += 4) {
202 if (rgba[i + 3] < 128) {
203 continue;
204 }
205 const key = (rgba[i] >> 4) * 256 + (rgba[i + 1] >> 4) * 16 + (rgba[i + 2] >> 4);
206 const b = buckets.get(key) || { n: 0, r: 0, g: 0, b: 0 };
207 b.n += 1;
208 b.r += rgba[i];
209 b.g += rgba[i + 1];
210 b.b += rgba[i + 2];
211 buckets.set(key, b);
212 }
213 if (buckets.size === 0) {
214 return { colors: FALLBACK_COLORS.map((c) => c.slice()), accent: FALLBACK_ACCENT.slice() };
215 }
216 // A grey cover has no colour to take: hum's own warm colours stand in rather than an arbitrary hue.
217 let colourful = 0;
218 for (const b of buckets.values()) {
219 if (rgbToHsl([b.r / b.n, b.g / b.n, b.b / b.n])[1] > 0.14) {
220 colourful += b.n;
221 }
222 }
223 if (colourful < rgba.length / 4 / 40) {
224 return { colors: FALLBACK_COLORS.map((c) => c.slice()), accent: FALLBACK_ACCENT.slice() };
225 }
226 const ranked = [...buckets.values()]
227 .map((b) => {
228 const rgb = [b.r / b.n, b.g / b.n, b.b / b.n];
229 const [, s, l] = rgbToHsl(rgb);
230 return { rgb, score: b.n * (0.25 + s * 1.6) * (l < 0.07 || l > 0.95 ? 0.15 : 1) };
231 })
232 .sort((a, b) => b.score - a.score);
233 const picks = [];
234 for (const r of ranked) {
235 if (picks.every((p) => distance(p, r.rgb) > 58)) {
236 picks.push(r.rgb);
237 }
238 if (picks.length === 4) {
239 break;
240 }
241 }
242 while (picks.length < 4) {
243 const [h, s, l] = rgbToHsl(picks[0] || [90, 60, 140]);
244 picks.push(hslToRgb([(h + 0.08 * picks.length) % 1, s, l]));
245 }
246 // Lifted into a glowing but dark range, so text reads on top.
247 const colors = picks.map((rgb, i) => {
248 const [h, s, l] = rgbToHsl(rgb);
249 return hslToRgb([h, Math.max(s, 0.38), i === 3 ? 0.07 : Math.min(Math.max(l, 0.3), 0.52)]);
250 });
251 const vivid = [...picks].sort((a, b) => rgbToHsl(b)[1] - rgbToHsl(a)[1])[0];
252 const [ah, as] = rgbToHsl(vivid);
253 return { colors, accent: hslToRgb([ah, Math.max(as, 0.55), 0.7]) };
254}
255
256export function hex(rgb) {
257 return `#${rgb.map((v) => clampByte(v).toString(16).padStart(2, "0")).join("")}`;
258}
259
260function clampByte(v) {
261 return Math.max(0, Math.min(255, Math.round(v)));
262}
263
264function pack(r, g, b) {
265 return (clampByte(r) << 16) | (clampByte(g) << 8) | clampByte(b);
266}
267
268// ---------- terminal cells ----------
269
270// Cell words for a Raster: [code point, foreground, background] per cell, row by row.
271// Base64 of the little-endian bytes is the Raster's `cells`.
272export function encodeCells(words) {
273 const bytes = new Uint8Array(words.length * 4);
274 for (let i = 0; i < words.length; i += 1) {
275 const w = words[i];
276 bytes[i * 4] = w & 255;
277 bytes[i * 4 + 1] = (w >>> 8) & 255;
278 bytes[i * 4 + 2] = (w >>> 16) & 255;
279 bytes[i * 4 + 3] = (w >>> 24) & 255;
280 }
281 return toBase64(bytes);
282}
283
284export function mix(a, b, t) {
285 return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
286}
287
288// A colour kept in its hue at the lightness asked for (0 to 1), and at least `minSat` saturated.
289export function tone(rgb, lightness, minSat = 0) {
290 const [h, s] = rgbToHsl(rgb);
291 return hslToRgb([h, Math.max(s, minSat), lightness]);
292}
293
294// A deep shade of the cover's strongest colour for the play button. Yellows and greens turn to
295// mud when they are darkened, so they are swung towards amber first.
296export function pillColor(rgb) {
297 const [h] = rgbToHsl(rgb);
298 const swung = h > 0.1 && h < 0.3 ? 0.1 - (0.3 - h) * 0.15 : h;
299 return hslToRgb([swung, 0.6, 0.36]);
300}
301
302function luma(c) {
303 return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
304}
305
306function smooth(a, b, x) {
307 const t = Math.max(0, Math.min(1, (x - a) / (b - a)));
308 return t * t * (3 - 2 * t);
309}
310
311// The part of a picture that fits a cols x rows block of cells. A cell is about twice as tall
312// as it is wide, so the block's own shape is cols : 2 * rows, and a picture of another shape is
313// cropped from its middle rather than squeezed.
314export function cropRect(width, height, cols, rows) {
315 const shape = cols / (rows * 2);
316 let cw = width;
317 let ch = height;
318 if (width / height > shape) {
319 cw = Math.max(1, Math.round(height * shape));
320 } else {
321 ch = Math.max(1, Math.round(width / shape));
322 }
323 return { x0: Math.floor((width - cw) / 2), y0: Math.floor((height - ch) / 2), cw, ch };
324}
325
326// Box-averages a part of an RGBA picture down to cols x rows pixels of RGB, over the background where see-through.
327function resample(rgba, width, height, cols, rows, rect = { x0: 0, y0: 0, cw: width, ch: height }) {
328 const out = new Float64Array(cols * rows * 3);
329 for (let y = 0; y < rows; y += 1) {
330 const y0 = rect.y0 + Math.floor((y * rect.ch) / rows);
331 const y1 = Math.max(y0 + 1, rect.y0 + Math.floor(((y + 1) * rect.ch) / rows));
332 for (let x = 0; x < cols; x += 1) {
333 const x0 = rect.x0 + Math.floor((x * rect.cw) / cols);
334 const x1 = Math.max(x0 + 1, rect.x0 + Math.floor(((x + 1) * rect.cw) / cols));
335 let r = 0;
336 let g = 0;
337 let b = 0;
338 let n = 0;
339 for (let sy = y0; sy < y1 && sy < height; sy += 1) {
340 for (let sx = x0; sx < x1 && sx < width; sx += 1) {
341 const at = (sy * width + sx) * 4;
342 const a = rgba[at + 3] / 255;
343 r += rgba[at] * a + BACKGROUND[0] * (1 - a);
344 g += rgba[at + 1] * a + BACKGROUND[1] * (1 - a);
345 b += rgba[at + 2] * a + BACKGROUND[2] * (1 - a);
346 n += 1;
347 }
348 }
349 const to = (y * cols + x) * 3;
350 const k = Math.max(1, n);
351 out[to] = r / k;
352 out[to + 1] = g / k;
353 out[to + 2] = b / k;
354 }
355 }
356 return out;
357}
358
359// A picture as half blocks: each cell is ▀ with the upper pixel as its foreground and the
360// lower as its background, so cols x rows cells carry cols x 2*rows pixels.
361export function halfBlockCells(rgba, width, height, cols, rows) {
362 const px = resample(rgba, width, height, cols, rows * 2);
363 const words = new Uint32Array(cols * rows * 3);
364 for (let y = 0; y < rows; y += 1) {
365 for (let x = 0; x < cols; x += 1) {
366 const top = (y * 2 * cols + x) * 3;
367 const bottom = ((y * 2 + 1) * cols + x) * 3;
368 const at = (y * cols + x) * 3;
369 words[at] = UPPER_HALF;
370 words[at + 1] = pack(px[top], px[top + 1], px[top + 2]);
371 words[at + 2] = pack(px[bottom], px[bottom + 1], px[bottom + 2]);
372 }
373 }
374 return words;
375}
376
377// A gentle lift before the picture is cut down to a few pixels, which is what makes a cover
378// read crisply at this size: a little more contrast and colour, then an unsharp mask (the
379// picture minus a blur of itself, added back). `px` is w x h pixels of RGB, 0 to 255; the
380// pixels are twice as tall as they are wide on screen, so the blur is narrower downwards.
381export function enhance(px, w, h, { contrast = 1.1, saturation = 1.2, sharpen = 0.75 } = {}) {
382 const out = new Float64Array(px.length);
383 for (let i = 0; i < px.length; i += 3) {
384 const l = luma([px[i], px[i + 1], px[i + 2]]);
385 for (let k = 0; k < 3; k += 1) {
386 let v = l + (px[i + k] - l) * saturation;
387 v = 127.5 + (v - 127.5) * contrast;
388 out[i + k] = Math.max(0, Math.min(255, v));
389 }
390 }
391 if (!(sharpen > 0) || w < 3 || h < 3) {
392 return out;
393 }
394 const across = new Float64Array(px.length);
395 for (let y = 0; y < h; y += 1) {
396 for (let x = 0; x < w; x += 1) {
397 const a = (y * w + Math.max(0, x - 1)) * 3;
398 const b = (y * w + x) * 3;
399 const c = (y * w + Math.min(w - 1, x + 1)) * 3;
400 for (let k = 0; k < 3; k += 1) {
401 across[b + k] = (out[a + k] + 2 * out[b + k] + out[c + k]) / 4;
402 }
403 }
404 }
405 const sharp = new Float64Array(px.length);
406 for (let y = 0; y < h; y += 1) {
407 for (let x = 0; x < w; x += 1) {
408 const a = (Math.max(0, y - 1) * w + x) * 3;
409 const b = (y * w + x) * 3;
410 const c = (Math.min(h - 1, y + 1) * w + x) * 3;
411 for (let k = 0; k < 3; k += 1) {
412 const blur = (across[a + k] + 6 * across[b + k] + across[c + k]) / 8;
413 sharp[b + k] = Math.max(0, Math.min(255, out[b + k] + sharpen * (out[b + k] - blur)));
414 }
415 }
416 }
417 return sharp;
418}
419
420// The best way to show four pixels (upper left, upper right, lower left, lower right; each
421// [r, g, b]) in one cell: a glyph of the sixteen quadrant blocks and a colour for each side,
422// whichever leaves the least squared error. The lighter side is the foreground. When
423// nothing beats one flat colour the cell is a space of that colour. Returns { glyph, fg, bg }.
424export function quadrantFit(p) {
425 const mean = (mask) => {
426 let n = 0;
427 const sum = [0, 0, 0];
428 for (let i = 0; i < 4; i += 1) {
429 if (mask & (1 << i)) {
430 n += 1;
431 sum[0] += p[i][0];
432 sum[1] += p[i][1];
433 sum[2] += p[i][2];
434 }
435 }
436 return [sum[0] / n, sum[1] / n, sum[2] / n];
437 };
438 const error = (mask, c) => {
439 let e = 0;
440 for (let i = 0; i < 4; i += 1) {
441 if (mask & (1 << i)) {
442 e += (p[i][0] - c[0]) ** 2 + (p[i][1] - c[1]) ** 2 + (p[i][2] - c[2]) ** 2;
443 }
444 }
445 return e;
446 };
447 const flat = mean(15);
448 const flatError = error(15, flat);
449 let best = null;
450 for (let m = 1; m <= 7; m += 1) {
451 const a = mean(m);
452 const b = mean(15 - m);
453 const e = error(m, a) + error(15 - m, b);
454 if (best === null || e < best.e) {
455 best = { m, a, b, e };
456 }
457 }
458 if (best === null || best.e + 1 >= flatError) {
459 return { glyph: 0x20, fg: flat, bg: flat };
460 }
461 return luma(best.a) >= luma(best.b)
462 ? { glyph: QUADRANTS[best.m].codePointAt(0), fg: best.a, bg: best.b }
463 : { glyph: QUADRANTS[15 - best.m].codePointAt(0), fg: best.b, bg: best.a };
464}
465
466// The nearest colour on the 16-level grid Claude Code paints a Raster in.
467export function snapColor(c) {
468 return [0, 1, 2].map((k) => Math.max(0, Math.min(255, Math.round(c[k] / GRID) * GRID)));
469}
470
471// Like quadrantFit for pixels that already sit on the grid: the two colours are chosen from the
472// pixels themselves, so nothing has to be averaged off the grid, whichever pair leaves
473// the least squared error. The lighter is the foreground.
474export function quadrantSnap(p) {
475 const uniq = [];
476 for (const c of p) {
477 if (!uniq.some((u) => u[0] === c[0] && u[1] === c[1] && u[2] === c[2])) {
478 uniq.push(c);
479 }
480 }
481 if (uniq.length === 1) {
482 return { glyph: 0x20, fg: uniq[0], bg: uniq[0] };
483 }
484 const dist = (a, b) => (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2;
485 let best = null;
486 for (let i = 0; i < uniq.length; i += 1) {
487 for (let j = i + 1; j < uniq.length; j += 1) {
488 const light = luma(uniq[i]) >= luma(uniq[j]) ? uniq[i] : uniq[j];
489 const dark = light === uniq[i] ? uniq[j] : uniq[i];
490 let mask = 0;
491 let e = 0;
492 for (let k = 0; k < 4; k += 1) {
493 const dl = dist(p[k], light);
494 const dd = dist(p[k], dark);
495 if (dl <= dd) {
496 mask |= 1 << k;
497 e += dl;
498 } else {
499 e += dd;
500 }
501 }
502 if (best === null || e < best.e) {
503 best = { e, mask, light, dark };
504 }
505 }
506 }
507 if (best.mask === 0 || best.mask === 15) {
508 return { glyph: 0x20, fg: best.light, bg: best.light };
509 }
510 return { glyph: QUADRANTS[best.mask].codePointAt(0), fg: best.light, bg: best.dark };
511}
512
513// Cells from a grid of sub-pixels (rgb, 2 * cols wide and 2 * rows tall). With `onGrid` the
514// pixels are on the colour grid already and the cell's colours are picked from them; without, they
515// are averaged and then snapped to the grid.
516function quadrantWords(sub, cols, rows, onGrid = false) {
517 const words = new Uint32Array(cols * rows * 3);
518 const sw = cols * 2;
519 const at = (x, y) => {
520 const i = (y * sw + x) * 3;
521 return [sub[i], sub[i + 1], sub[i + 2]];
522 };
523 for (let row = 0; row < rows; row += 1) {
524 for (let col = 0; col < cols; col += 1) {
525 const four = [at(col * 2, row * 2), at(col * 2 + 1, row * 2), at(col * 2, row * 2 + 1), at(col * 2 + 1, row * 2 + 1)];
526 const cell = onGrid ? quadrantSnap(four) : quadrantFit(four);
527 const fg = onGrid ? cell.fg : snapColor(cell.fg);
528 const bg = onGrid ? cell.bg : snapColor(cell.bg);
529 const to = (row * cols + col) * 3;
530 words[to] = cell.glyph;
531 words[to + 1] = pack(fg[0], fg[1], fg[2]);
532 words[to + 2] = pack(bg[0], bg[1], bg[2]);
533 }
534 }
535 return words;
536}
537
538// A picture as quadrant blocks: cols x rows cells carrying 2 * cols x 2 * rows pixels, each cell
539// two colours and a glyph. The picture is cropped to the block's shape, cut down, given the
540// lift of `enhance`, then every cell is fitted (see quadrantFit). Pass `{ sharpen: 0 }` and the
541// like to change the lift.
542export function quadrantCells(rgba, width, height, cols, rows, lift) {
543 const sw = cols * 2;
544 const sh = rows * 2;
545 const px = resample(rgba, width, height, sw, sh, cropRect(width, height, cols, rows));
546 return quadrantWords(enhance(px, sw, sh, lift), cols, rows);
547}
548
549// Stand-in cover when there is no picture: a soft diagonal wash of the palette.
550export function placeholderCells(cols, rows, colors) {
551 const w = cols * 2;
552 const h = rows * 2;
553 const rgba = new Uint8Array(w * h * 4);
554 for (let y = 0; y < h; y += 1) {
555 for (let x = 0; x < w; x += 1) {
556 const k = (x / Math.max(1, w - 1) + y / Math.max(1, h - 1)) / 2;
557 const from = k < 0.5 ? colors[0] : colors[1];
558 const to = k < 0.5 ? colors[1] : colors[2];
559 const f = k < 0.5 ? k * 2 : (k - 0.5) * 2;
560 const at = (y * w + x) * 4;
561 rgba[at] = from[0] + (to[0] - from[0]) * f;
562 rgba[at + 1] = from[1] + (to[1] - from[1]) * f;
563 rgba[at + 2] = from[2] + (to[2] - from[2]) * f;
564 rgba[at + 3] = 255;
565 }
566 }
567 return quadrantCells(rgba, w, h, cols, rows, { contrast: 1, saturation: 1, sharpen: 0 });
568}
569
570// ---------- aurora ----------
571
572// An ordered dither threshold in (0, 1) for a sub-pixel, from the 4x4 Bayer matrix.
573const BAYER4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
574export function bayer(x, y) {
575 return (BAYER4[(y & 3) * 4 + (x & 3)] + 0.5) / 16;
576}
577
578function lattice(ix, iy, seed) {
579 let h = (Math.imul(ix, 0x27d4eb2d) ^ Math.imul(iy, 0x165667b1) ^ Math.imul(seed, 0x9e3779b1)) | 0;
580 h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
581 h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
582 h ^= h >>> 16;
583 return (h >>> 0) / 4294967296;
584}
585
586// Smooth value noise, 0 to 1.
587function noise(x, y, seed) {
588 const ix = Math.floor(x);
589 const iy = Math.floor(y);
590 const fx = x - ix;
591 const fy = y - iy;
592 const u = fx * fx * (3 - 2 * fx);
593 const v = fy * fy * (3 - 2 * fy);
594 const a = lattice(ix, iy, seed);
595 const b = lattice(ix + 1, iy, seed);
596 const c = lattice(ix, iy + 1, seed);
597 const d = lattice(ix + 1, iy + 1, seed);
598 return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
599}
600
601// Three octaves of it, 0 to 1 and mostly near the middle.
602export function fbm(x, y, seed) {
603 return noise(x, y, seed) * 0.57 + noise(x * 2.03 + 17.1, y * 2.03 + 3.7, seed + 1) * 0.29 + noise(x * 4.1 + 5.3, y * 4.1 + 9.2, seed + 2) * 0.14;
604}
605
606const AURORA_HUES = 20;
607const AURORA_PEAK = 135;
608
609// The aurora: a slow ribbon of the cover's colours, brightest where its lobes gather and gone
610// to nothing at its ends and edges, so it glows out of the dark. `t` is seconds and `level`
611// (0 to 1) dims it. It is worked out on a grid of quadrant sub-pixels (twice as many across
612// and down as there are cells) in smooth noise. Claude Code paints a Raster in 16 levels a
613// channel, which on a dark glow is a few steps and shows as bands, so each sub-pixel is
614// rounded to the grid with an ordered (Bayer) dither: the two nearest colours are woven into
615// each other and the eye reads the level between. Each cell then takes the quadrant block that
616// fits its four sub-pixels.
617export function auroraCells(columns, rows, t, colors, level = 1) {
618 const words = new Uint32Array(columns * rows * 3);
619 if (columns < 1 || rows < 1) {
620 return words;
621 }
622 const sw = columns * 2;
623 const sh = rows * 2;
624 const drift = t * 0.045;
625 const edge = Math.min(18, Math.max(3, columns * 0.22));
626 const lobe = new Float64Array(sw);
627 const centre = new Float64Array(sw);
628 const spread = new Float64Array(sw);
629 const hue = [];
630 const glow = [];
631 // Light of the cover's colours, kept lively: a dull cover still gives a glow with some colour in it.
632 const ramp = [colors[0], colors[1], colors[2]].map((c) => {
633 const [h, s, l] = rgbToHsl(c);
634 return hslToRgb([h, Math.max(s, 0.5), Math.min(0.62, Math.max(l, 0.46))]);
635 });
636 for (let sx = 0; sx < sw; sx += 1) {
637 const x = (sx + 0.5) / 2;
638 const gather = smooth(0.26, 0.8, fbm(x * 0.06 + drift * 2.2, 1.7, 11));
639 const ray = 0.8 + 0.2 * noise(x * 0.8 - drift * 5, 4.2, 23);
640 const ends = smooth(0, edge, x) * smooth(0, edge, columns - x);
641 lobe[sx] = (0.1 + 0.9 * gather) * ray * ends * ends;
642 centre[sx] = sh * (0.5 + 0.2 * (fbm(x * 0.04 - drift * 2.4, 8.3, 31) - 0.5) * 2);
643 spread[sx] = Math.max(1.1, sh * (0.22 + 0.08 * fbm(x * 0.07 + drift * 2, 2.2, 41)));
644 const g = 0.1 + (x / columns) * 0.6 + 0.32 * fbm(x * 0.03 - drift * 1.4, 5.9, 53) + drift * 0.5;
645 const step = Math.round((((g % 1) + 1) % 1) * AURORA_HUES) / AURORA_HUES;
646 const k = step * 3;
647 const i = Math.floor(k) % 3;
648 const f = smooth(0, 1, k - Math.floor(k));
649 const c = mix(ramp[i], ramp[(i + 1) % 3], f);
650 hue.push(c);
651 // the same peak brightness for any colour: a pale yellow is held back, a deep purple pushed
652 glow.push(Math.max(0.9, Math.min(1.7, AURORA_PEAK / Math.max(40, luma(c)))));
653 }
654 const sub = new Float64Array(sw * sh * 3);
655 for (let sy = 0; sy < sh; sy += 1) {
656 const y = sy + 0.5;
657 const fadeIn = Math.min(2.4, sh * 0.36);
658 const window = smooth(0, fadeIn, y) * smooth(0, fadeIn * 1.15, sh - y);
659 for (let sx = 0; sx < sw; sx += 1) {
660 const d = (y - centre[sx]) / spread[sx];
661 // a bright core in a wide, faint haze, so it reads as light spilling and not as a bar
662 const light = Math.max(0, (Math.exp(-0.5 * d * d) * 0.82 + Math.exp(-0.5 * (d / 3) * (d / 3)) * 0.45) * lobe[sx] * window * level);
663 const threshold = bayer(sx, sy);
664 const at = (sy * sw + sx) * 3;
665 const c = hue[sx];
666 for (let k = 0; k < 3; k += 1) {
667 const value = PANEL_BG[k] + c[k] * glow[sx] * light;
668 sub[at + k] = Math.max(0, Math.min(15, Math.floor(value / GRID + threshold))) * GRID;
669 }
670 }
671 }
672 return quadrantWords(sub, columns, rows, true);
673}
674
675// ---------- layout ----------
676
677// Which view to draw. `big` is the person's choice, `isSong` is true while hum has a track
678// (the radio always keeps its band). `rows` is the room the band may take.
679// Returns { mode: "band" } or { mode: "panel", cover: "image" | "cells" | "svg" }.
680export function chooseLayout({ big, isSong, surface, columns, rows, canImages }) {
681 const band = { mode: "band" };
682 if (!big || !isSong) {
683 return band;
684 }
685 if (surface === "terminal") {
686 if (!(columns >= PANEL_MIN_COLUMNS) || !(rows >= PANEL_ROWS + PANEL_ROOM)) {
687 return band;
688 }
689 return { mode: "panel", cover: canImages ? "image" : "cells" };
690 }
691 if (surface === "desktop") {
692 return { mode: "panel", cover: "svg" };
693 }
694 return band;
695}
696
697// Whether this terminal draws pictures: kitty and Ghostty do, and tmux passes none through.
698// `env` holds the values of TERM_PROGRAM, TERM, KITTY_WINDOW_ID, GHOSTTY_RESOURCES_DIR and TMUX,
699// and HUSH_COVER ("cells" or "image") forces the answer.
700export function canShowImages(env) {
701 const forced = String(env.HUSH_COVER || "").toLowerCase();
702 if (forced === "cells") {
703 return false;
704 }
705 if (forced === "image") {
706 return true;
707 }
708 if (env.TMUX) {
709 return false;
710 }
711 const program = String(env.TERM_PROGRAM || "").toLowerCase();
712 const term = String(env.TERM || "").toLowerCase();
713 return Boolean(env.KITTY_WINDOW_ID || env.GHOSTTY_RESOURCES_DIR) || program === "ghostty" || term.includes("kitty") || term.includes("ghostty");
714}
715
716// Cells across the panel's right-hand column: what is left after padding, the cover and the gap.
717export function panelWidths(columns, cover = COVER_COLUMNS, gap = PANEL_GAP) {
718 const text = Math.max(10, columns - PANEL_PAD * 2 - cover - gap);
719 return { cover, gap, text, bar: Math.max(6, text - 15) };
720}
721
722// How big the terminal panel is, measured from the room the engine gives (`maxRows`, what the
723// band may take) and the cells across. The cover is square (twice as many columns as rows, a
724// cell being about twice as tall as wide) and as tall as the room allows, 8 to 10 rows, leaving
725// one row of slack for the focus hint. The words beside it are a two row ribbon of aurora and six
726// lines (title, artist, two caption lines, progress, buttons); a ninth row adds a gap under the
727// artist and a tenth another above the progress. Narrow terminals keep the smaller cover. A row
728// of padding above and below is added when there is plenty of room.
729// Returns { rows, cover, gap, text, bar, ribbon, gapA, gapB, pad }.
730export function panelGeometry(columns, maxRows) {
731 let rows = Math.max(PANEL_ROWS, Math.min(PANEL_ROWS_MAX, Math.floor(maxRows) - PANEL_ROOM));
732 if (columns < 76) {
733 rows = Math.min(rows, PANEL_ROWS);
734 }
735 const cover = rows * 2;
736 const gap = rows >= 9 ? 3 : PANEL_GAP;
737 const w = panelWidths(columns, cover, gap);
738 return {
739 rows,
740 cover,
741 gap,
742 text: w.text,
743 bar: Math.max(6, w.text - 12),
744 ribbon: RIBBON_ROWS,
745 gapA: rows >= 9 ? 1 : 0,
746 gapB: rows >= 10 ? 1 : 0,
747 pad: maxRows >= rows + 4 ? 1 : 0,
748 };
749}
750
751// ---------- progress ----------
752
753// The progress bar as filled and empty cell counts.
754export function progressBar(pos, dur, cells) {
755 const share = dur > 0 ? Math.max(0, Math.min(1, pos / dur)) : 0;
756 const filled = Math.round(share * cells);
757 return { filled, empty: cells - filled };
758}
759
760// The bar to eighths of a cell: whole cells, then one of ▏▎▍▌▋▊▉ for the part, then the rest.
761export function progressEighths(pos, dur, cells) {
762 const share = dur > 0 ? Math.max(0, Math.min(1, pos / dur)) : 0;
763 const eighths = Math.round(share * cells * 8);
764 return { full: Math.floor(eighths / 8), part: eighths % 8, cells, eighths };
765}
766
767// The bar as runs of text to draw: { text, color: [r, g, b], track }. The filled part is
768// a gradient from `from` to `to` across the filled cells (so both colours show from the start),
769// cut into a few steps so neighbouring cells share a run; the part cell is an eighth block over the
770// track; the rest is track (a run of spaces on a dim background).
771export function progressRuns(pos, dur, cells, from, to) {
772 const { full, part } = progressEighths(pos, dur, cells);
773 const filled = full + (part > 0 ? 1 : 0);
774 const runs = [];
775 const push = (text, color, track) => {
776 const last = runs[runs.length - 1];
777 if (last && last.track === track && last.color[0] === color[0] && last.color[1] === color[1] && last.color[2] === color[2] && !last.single) {
778 last.text += text;
779 } else {
780 runs.push({ text, color, track });
781 }
782 };
783 for (let i = 0; i < full; i += 1) {
784 const k = filled > 1 ? Math.round((i / (filled - 1)) * 8) / 8 : 0;
785 push(EIGHTHS[8], mix(from, to, k).map(Math.round), false);
786 }
787 if (part > 0) {
788 const k = filled > 1 ? 1 : 0;
789 runs.push({ text: EIGHTHS[part], color: mix(from, to, k).map(Math.round), track: true, single: true });
790 }
791 if (cells - filled > 0) {
792 push(" ".repeat(cells - filled), TRACK, true);
793 }
794 return runs;
795}
796
797// Times that keep their width, so the bar does not shift as the minutes roll over: both are
798// as wide as the song's length.
799export function timeLabels(pos, dur) {
800 const total = dur > 0 ? clock(dur) : "";
801 const width = Math.max(total.length, 4);
802 return { elapsed: clock(pos).padStart(width, " "), total: total === "" ? "" : total.padStart(width, " ") };
803}
804
805// ---------- caption ----------
806
807// The two lines under the artist. With synced lyrics: the line being sung (an interlude is
808// three dots) and the next one. Without: the album, and what is up next, so there is never a
809// gap. Each is { role, label, text }; role is "now", "next", "album", "queue" or "quiet".
810export function captionLines(song) {
811 const queue = song.upNext ? { role: "queue", label: "up next", text: song.upNext } : null;
812 if (song.hasLyrics) {
813 return {
814 kind: "lyrics",
815 first: { role: "now", label: "", text: song.line || "· · ·" },
816 second: song.next ? { role: "next", label: "", text: song.next } : queue || { role: "quiet", label: "", text: "· · ·" },
817 };
818 }
819 return {
820 kind: "about",
821 first: { role: "album", label: "", text: song.album || "no synced lyrics for this one" },
822 second: queue || { role: "quiet", label: "", text: song.album ? "no synced lyrics for this one" : "end of the queue" },
823 };
824}
825
826// ---------- cover files ----------
827
828// A folder name for a track: its id when it is a plain one, else a hash of title and artist.
829export function coverKey(track) {
830 const id = track && typeof track.id === "string" ? track.id : "";
831 if (/^[A-Za-z0-9_-]{4,40}$/.test(id)) {
832 return id;
833 }
834 const text = `${track?.title || ""}|${track?.artist || ""}`;
835 let hash = 5381;
836 for (let i = 0; i < text.length; i += 1) {
837 hash = (Math.imul(hash, 33) + text.charCodeAt(i)) >>> 0;
838 }
839 return `t${hash.toString(16)}`;
840}
841
842// Where to fetch a cover from: hum's art, then YouTube's thumbnail. http and https only.
843export function coverUrls(track) {
844 const urls = [];
845 if (track && typeof track.art === "string" && /^https?:\/\//.test(track.art)) {
846 urls.push(track.art);
847 }
848 if (track && typeof track.id === "string" && /^[A-Za-z0-9_-]{6,20}$/.test(track.id)) {
849 urls.push(`https://i.ytimg.com/vi/${track.id}/mqdefault.jpg`);
850 }
851 return urls;
852}
853
854export const COVER_KEEP = 40;
855
856// A shell script for one cover, run by /bin/sh with: folder, art folder, how many to keep, then
857// the urls. It downloads with curl and makes cover.png (the terminal's Image), cover.jpg (the
858// desktop's Svg) and cells.bmp (read in JS for the cell art and the colours), all with sips.
859// cells.bmp is 64 pixels square, enough for a 20 column cover at two pixels a cell with room to
860// average. A folder that already has the three files is only touched, which keeps the newest ones.
861export const COVER_SCRIPT = `
862set -e
863d="$1"; root="$2"; keep="$3"; shift 3
864if [ -s "$d/cover.png" ] && [ -s "$d/cover.jpg" ] && [ -s "$d/cells.bmp" ]; then /usr/bin/touch "$d"; exit 0; fi
865/bin/mkdir -p "$d"
866trap 'rc=$?; [ "$rc" -ne 0 ] && /bin/rm -rf "$d"; exit "$rc"' EXIT
867cd "$d"
868got=0
869for u in "$@"; do
870 if /usr/bin/curl -fsSL --proto '=http,https' --max-time 10 --max-filesize 8000000 -o raw "$u" 2>/dev/null; then got=1; break; fi
871done
872[ "$got" = 1 ]
873w=$(/usr/bin/sips -g pixelWidth raw | /usr/bin/awk '/pixelWidth/ {print $2}')
874h=$(/usr/bin/sips -g pixelHeight raw | /usr/bin/awk '/pixelHeight/ {print $2}')
875s=$(( w < h ? w : h ))
876/usr/bin/sips -c "$s" "$s" -s format png raw --out sq.png >/dev/null
877/usr/bin/sips -z 192 192 -s format png sq.png --out cover.png >/dev/null
878/usr/bin/sips -z 216 216 -s format jpeg -s formatOptions 68 sq.png --out cover.jpg >/dev/null
879/usr/bin/sips -z 64 64 -s format bmp sq.png --out cells.bmp >/dev/null
880/bin/rm -f raw sq.png
881trap - EXIT
882if [ -n "$root" ] && [ "$keep" -gt 0 ]; then
883 cd "$root"
884 /bin/ls -1t | /usr/bin/tail -n +"$((keep + 1))" | while IFS= read -r old; do /bin/rm -rf "$root/$old"; done
885fi
886`;
887
888// ---------- desktop svg ----------
889
890function esc(text) {
891 return String(text).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
892}
893
894// The desktop's picture: the cover with rounded corners on a haze of itself (the cover blown
895// up and blurred, so the card takes its colours), a soft shadow of its first colour, a drifting
896// glow, then the title, artist and album and three bars that dance while it plays. The glow and
897// bars move by SMIL when `animated`. It holds nothing that changes while a song plays (no
898// lyric, no clock), so the same source is drawn every second and the animation keeps going.
899export function panelSvg({ title, artist, album = "", colors, accent, jpeg, animated, dim }) {
900 const [c0, c1, c2, c3] = colors.map(hex);
901 const ac = hex(accent);
902 const move = (attr, values, dur) =>
903 animated ? `<animate attributeName="${attr}" values="${values}" dur="${dur}s" repeatCount="indefinite" calcMode="spline" keyTimes="0;0.5;1" keySplines="0.45 0 0.55 1;0.45 0 0.55 1"/>` : "";
904 const blob = (cx, cy, r, color, opacity, moves) =>
905 `<ellipse cx="${cx}" cy="${cy}" rx="${r}" ry="${Math.round(r * 0.7)}" fill="${color}" fill-opacity="${opacity}">${moves}</ellipse>`;
906 const bar = (x, low, high, dur) => {
907 const h = animated ? low : Math.round((low + high) / 2);
908 const motion = animated
909 ? `<animate attributeName="height" values="${low};${high};${low}" dur="${dur}s" repeatCount="indefinite" calcMode="spline" keyTimes="0;0.5;1" keySplines="0.45 0 0.55 1;0.45 0 0.55 1"/>` +
910 `<animate attributeName="y" values="${46 - low};${46 - high};${46 - low}" dur="${dur}s" repeatCount="indefinite" calcMode="spline" keyTimes="0;0.5;1" keySplines="0.45 0 0.55 1;0.45 0 0.55 1"/>`
911 : "";
912 return `<rect x="${x}" y="${46 - h}" width="3" height="${h}" rx="1.5" fill="${ac}" fill-opacity="${dim ? 0.45 : 0.95}">${motion}</rect>`;
913 };
914 const glowOpacity = dim ? 0.18 : 0.42;
915 const hazeOpacity = dim ? 0.3 : 0.62;
916 const art = jpeg
917 ? `<image id="art" href="data:image/jpeg;base64,${jpeg}" width="108" height="108" preserveAspectRatio="xMidYMid slice"/>`
918 : `<rect id="art" width="108" height="108" fill="url(#wash)"/>`;
919 const sub = album && album !== title ? `<text x="152" y="130" font-size="12" fill="${BONE}" fill-opacity="0.42">${esc(fit(album, 52))}</text>` : "";
920 const svg = [
921 `<svg xmlns="http://www.w3.org/2000/svg" width="${SVG_WIDTH}" height="${SVG_HEIGHT}" viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}" font-family="-apple-system, 'Helvetica Neue', Arial, sans-serif">`,
922 `<defs>`,
923 art,
924 `<clipPath id="frame"><rect width="${SVG_WIDTH}" height="${SVG_HEIGHT}" rx="16"/></clipPath>`,
925 `<clipPath id="cover"><rect x="20" y="20" width="108" height="108" rx="14"/></clipPath>`,
926 `<clipPath id="words"><rect x="152" y="0" width="${SVG_WIDTH - 152 - 20}" height="${SVG_HEIGHT}"/></clipPath>`,
927 `<filter id="soft" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="30"/></filter>`,
928 `<filter id="haze" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="38"/></filter>`,
929 `<filter id="lift" x="-40%" y="-40%" width="180%" height="180%"><feGaussianBlur stdDeviation="13"/></filter>`,
930 `<linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${c0}"/><stop offset="0.6" stop-color="${c1}"/><stop offset="1" stop-color="${c2}"/></linearGradient>`,
931 `<linearGradient id="shade" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="${BACKGROUND_HEX}" stop-opacity="0.12"/><stop offset="0.32" stop-color="${BACKGROUND_HEX}" stop-opacity="0.55"/><stop offset="1" stop-color="${BACKGROUND_HEX}" stop-opacity="0.72"/></linearGradient>`,
932 `<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff" stop-opacity="0.16"/><stop offset="0.45" stop-color="#ffffff" stop-opacity="0"/></linearGradient>`,
933 `</defs>`,
934 `<g clip-path="url(#frame)">`,
935 `<rect width="${SVG_WIDTH}" height="${SVG_HEIGHT}" fill="${BACKGROUND_HEX}"/>`,
936 `<g filter="url(#haze)" opacity="${hazeOpacity}"><use href="#art" transform="translate(-110 -170) scale(7.4)"/></g>`,
937 `<g filter="url(#soft)">`,
938 blob(150, 40, 150, c0, glowOpacity, move("cx", "150;270;150", 17) + move("cy", "40;80;40", 23)),
939 blob(360, 120, 170, c1, glowOpacity, move("cx", "360;210;360", 21) + move("cy", "120;60;120", 19)),
940 blob(520, 30, 140, c2, glowOpacity, move("cx", "520;420;520", 25) + move("cy", "30;84;30", 29)),
941 blob(60, 140, 110, c3, glowOpacity + 0.1, move("cx", "60;150;60", 31)),
942 `</g>`,
943 `<rect width="${SVG_WIDTH}" height="${SVG_HEIGHT}" fill="url(#shade)"/>`,
944 `<rect x="26" y="34" width="96" height="104" rx="16" fill="${c0}" filter="url(#lift)" fill-opacity="${dim ? 0.35 : 0.8}"/>`,
945 `<g clip-path="url(#cover)"><use href="#art" x="20" y="20"/><rect x="20" y="20" width="108" height="108" fill="url(#sheen)"/></g>`,
946 `<rect x="20.5" y="20.5" width="107" height="107" rx="13.5" fill="none" stroke="${BONE}" stroke-opacity="0.16"/>`,
947 `<g clip-path="url(#words)">`,
948 `<text x="152" y="44" font-size="10" font-weight="600" letter-spacing="2.6" fill="${ac}" fill-opacity="${dim ? 0.6 : 0.95}">NOW PLAYING</text>`,
949 bar(256, 4, 12, 0.9),
950 bar(262, 10, 4, 1.1),
951 bar(268, 6, 13, 0.8),
952 `<text x="152" y="82" font-size="26" font-weight="700" letter-spacing="-0.3" fill="${BONE}"${dim ? ' fill-opacity="0.78"' : ""}>${esc(fit(title, 28))}</text>`,
953 `<text x="152" y="107" font-size="15" fill="${BONE}" fill-opacity="${dim ? 0.5 : 0.74}">${esc(fit(artist, 46))}</text>`,
954 sub,
955 `</g>`,
956 `</g>`,
957 `</svg>`,
958 ].join("");
959 // Too big only if the picture is: draw it again without.
960 if (svg.length > SVG_LIMIT && jpeg) {
961 return panelSvg({ title, artist, album, colors, accent, jpeg: null, animated, dim });
962 }
963 return svg;
964}
965