SLOPSHOPPER

hush

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…

newbandguardcommandtoastprompt
v0.6.0MITupdated 2026-10-03anzal1/hush
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hush
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /music ⎿ hush: Starting hum. The song plays in the hum tab once it connects; click that tab once if your browser asks. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Hush

Hush playing lofi above the Claude Code prompt

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.

  • Free: no account, no key. Streams are public stations from the open Radio Browser directory.
  • Nothing to install for radio: a small native helper (source in 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.
  • macOS only for now. Needs Claude Code 2.1.287 or later.

Use

/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.

The focused band, with controls

Big mode

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.

  • Terminal: the cover is a picture where the terminal shows them (kitty, Ghostty), and quadrant-block cell art elsewhere (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.
  • The aurora is a two row ribbon of cells repainted about eight times a second, only while the panel is on screen and the song plays. Claude Code paints a Raster in 16 levels a channel, which turns a dark glow into visible bands, so the ribbon is rounded to that grid with an ordered (Bayer) dither across the quadrant sub-pixels and fades to nothing at its ends and edges. For that reason the panel's background is #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.
  • Progress is drawn to eighths of a cell (▏▎▍▌▋▊▉█) in a gradient of the cover's two strongest colours over a dim track. Play or pause is a pill in a deep shade of the cover's colour; prev and next are plain words. All are real buttons.
  • Desktop (the Code tab in the Claude app): one SVG holds the cover with rounded corners on a haze of its own colours, the title, artist and album and three bars that dance while it plays (SMIL). The lyric lines, progress and buttons are native elements under it, so the SVG does not change each second and its animation is not restarted. Pausing or a question swaps in a still glow.

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.

Install

/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.

How it works

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.

Stations

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.

Any song, through hum

/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.

DJ Clawd

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.

Updating

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.

Tests

claude plugin test .

runs everything in tests/ against fake radio and hum servers, so nothing plays.

Source 4 files
hooks/register.mjs 1423 lines
1// 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 lines
1// 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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}
259
hooks/logic.mjs 198 lines
1// 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}
198
hooks/panel.mjs 965 lines
1// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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