SLOPSHOPPER

clawd-radio

A one-row radio above your prompt with 54 stations, including Singapore's, played in the terminal.

newbandprocess
v3.5.0MITupdated 2026-10-13HainanZhao/claude-mods/mods/clawd-radio
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-radio
› 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 ⟨Claude Code's own drawing⟩ Play Claude FM Lofi Girl Synthwave Groove Salad Fluid Beat Blender Secret Agent › ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Play Claude FM Lofi Girl Synthwave Groove Salad Fluid Beat Blender Secret Agent ›
README

Clawd Radio

A one-row radio above Claude Code's prompt, with 54 stations played in the terminal, Singapore's included. Choose all, Singapore only or international only under Stations in /config.

claude plugin marketplace add https://github.com/HainanZhao/claude-mods
claude plugin install clawd-radio@claude-mods

Needs ffplay and yt-dlp (brew install ffmpeg yt-dlp). See the project README for how to use it.

MIT licensed; see LICENSE.

Source 1 files
hooks/register.mjs 285 lines
1// The host statically reads literal on(...) and $.noun.method(...) calls, so
2// every helper using $ stays in this file.
3const STREAMTHEWORLD = 'https://playerservices.streamtheworld.com/api/livestream-redirect/';
4// Claude FM is the live stream /radio opens in a browser. YouTube streams need
5// yt-dlp to find today's audio URL; the rest are direct streams ffplay reads.
6const CATALOG = [
7  { name: 'Claude FM', url: 'https://www.youtube.com/watch?v=tRsQsTMvPNg', youtube: true },
8  { name: 'Lofi Girl', url: 'https://www.youtube.com/@LofiGirl/live', youtube: true },
9  { name: 'Synthwave', url: 'https://www.youtube.com/watch?v=4xDzrJKXOOY', youtube: true },
10  { name: 'Groove Salad', url: 'https://ice1.somafm.com/groovesalad-128-mp3' },
11  { name: 'Fluid', url: 'https://ice1.somafm.com/fluid-128-mp3' },
12  { name: 'Beat Blender', url: 'https://ice1.somafm.com/beatblender-128-mp3' },
13  { name: 'Secret Agent', url: 'https://ice1.somafm.com/secretagent-128-mp3' },
14  { name: 'Lush', url: 'https://ice1.somafm.com/lush-128-mp3' },
15  { name: 'Drone Zone', url: 'https://ice1.somafm.com/dronezone-128-mp3' },
16  { name: 'Deep Space', url: 'https://ice1.somafm.com/deepspaceone-128-mp3' },
17  { name: 'Top 100', url: 'https://streams.ilovemusic.de/iloveradio9.mp3' },
18  { name: 'PopTron', url: 'https://ice1.somafm.com/poptron-128-mp3' },
19  { name: 'Indie Pop', url: 'https://ice1.somafm.com/indiepop-128-mp3' },
20  { name: 'Hip Hop', url: 'https://streams.ilovemusic.de/iloveradio3.mp3' },
21  { name: 'Dance', url: 'https://streams.ilovemusic.de/iloveradio2.mp3' },
22  { name: 'US Rap', url: 'https://streams.ilovemusic.de/iloveradio13.mp3' },
23  { name: 'Old School', url: 'https://listen.181fm.com/181-oldschool_128k.mp3' },
24  { name: 'Radio Paradise', url: 'https://stream.radioparadise.com/mp3-192' },
25  { name: 'KEXP', url: 'https://kexp-mp3-128.streamguys1.com/kexp128.mp3' },
26  { name: 'Rock Mix', url: 'https://stream.radioparadise.com/rock-192' },
27  { name: 'Classic Rock', url: 'https://stream.rockantenne.de/classic-perlen/stream/mp3' },
28  { name: 'Hard Rock', url: 'https://listen.181fm.com/181-hardrock_128k.mp3' },
29  { name: 'Metal', url: 'https://ice1.somafm.com/metal-128-mp3' },
30  { name: 'Alt Rock', url: 'https://listen.181fm.com/181-90salt_128k.mp3' },
31  { name: 'Punk', url: 'https://stream.rockantenne.de/punkrock/stream/mp3' },
32  { name: '80s', url: 'https://ice1.somafm.com/u80s-128-mp3' },
33  { name: '70s', url: 'https://ice1.somafm.com/seventies-128-mp3' },
34  { name: 'Jazz', url: 'https://stream.srg-ssr.ch/m/rsj/mp3_128' },
35  { name: 'Nu Jazz', url: 'https://ice1.somafm.com/sonicuniverse-128-mp3' },
36  { name: 'Electro Swing', url: 'https://jazz-wr04.ice.infomaniak.ch/jazz-wr04-128.mp3' },
37  { name: 'Classical', url: 'https://stream.srg-ssr.ch/m/rsc_de/mp3_128' },
38  { name: 'Reggae', url: 'https://ice1.somafm.com/reggae-128-mp3' },
39  { name: 'Country', url: 'https://listen.181fm.com/181-kickincountry_128k.mp3' },
40  { name: 'Country Mix', url: 'https://strm112.1.fm/country_mobile_mp3' },
41  { name: 'Americana', url: 'https://ice1.somafm.com/bootliquor-128-mp3' },
42  { name: 'Folk', url: 'https://ice1.somafm.com/folkfwd-128-mp3' },
43  { name: 'DEF CON', url: 'https://ice1.somafm.com/defcon-128-mp3' },
44  { name: 'NTS', url: 'https://stream-relay-geo.ntslive.net/stream' },
45  // Singapore's Mediacorp and SPH stations, in English, Chinese, Malay and Tamil.
46  { name: 'Class 95', url: STREAMTHEWORLD + 'CLASS95AAC.aac', singapore: true },
47  { name: '987', url: STREAMTHEWORLD + '987FMAAC.aac', singapore: true },
48  { name: 'Kiss92', url: STREAMTHEWORLD + 'KISS_92AAC.aac', singapore: true },
49  { name: 'ONE FM 91.3', url: STREAMTHEWORLD + 'ONE_FM_913AAC.aac', singapore: true },
50  { name: 'Gold 905', url: STREAMTHEWORLD + 'GOLD905AAC.aac', singapore: true },
51  { name: 'CNA 938', url: STREAMTHEWORLD + '938NOWAAC.aac', singapore: true },
52  { name: 'Money 89.3', url: STREAMTHEWORLD + 'MONEY_893AAC.aac', singapore: true },
53  { name: 'Symphony 924', url: STREAMTHEWORLD + 'SYMPHONY924AAC.aac', singapore: true },
54  { name: 'YES 933', url: STREAMTHEWORLD + 'YES933AAC.aac', singapore: true },
55  { name: 'Love 972', url: STREAMTHEWORLD + 'LOVE972FMAAC.aac', singapore: true },
56  { name: 'Capital 958', url: STREAMTHEWORLD + 'CAPITAL958FMAAC.aac', singapore: true },
57  { name: 'UFM 100.3', url: STREAMTHEWORLD + 'UFM_1003AAC.aac', singapore: true },
58  { name: 'Hao 96.3', url: STREAMTHEWORLD + 'HAO_963AAC.aac', singapore: true },
59  { name: 'Ria 897', url: STREAMTHEWORLD + 'RIA897FMAAC.aac', singapore: true },
60  { name: 'Warna 942', url: STREAMTHEWORLD + 'WARNA942FMAAC.aac', singapore: true },
61  { name: 'Oli 968', url: STREAMTHEWORLD + 'OLI968FMAAC.aac', singapore: true },
62];
63// The `stations` setting narrows the catalog: all, singapore, or international.
64let STATIONS = CATALOG;
65let station = 0;
66// The first station in view once the arrows have scrolled; null centres the
67// view on the selected station. `shown` is the window last drawn.
68let viewFrom = null;
69let shown = null;
70let radio = 'off'; // off | tuning | on | error
71let radioStream = null;
72let radioGeneration = 0;
73// A spawned child dies with the dispatch that spawned it, and a Button's comes
74// from a render the host drops when the band redraws (as when a task ends).
75// So ffplay runs in a worker under session.start; presses only post to it.
76let radioWanted = false;
77let wakeRadio = null;
78let active = false;
79let generation = 0;
80
81export function register(on, options = {}) {
82  const only = options.stations;
83  STATIONS = only === 'singapore' ? CATALOG.filter(({ singapore }) => singapore)
84    : only === 'international' ? CATALOG.filter(({ singapore }) => !singapore)
85      : CATALOG;
86  on('session.start', async ($, e, next) => {
87    const mine = ++generation;
88    active = false;
89    stopRadio();
90    wakeRadio?.();
91    const result = await next(e);
92    // The last station chosen, by name so a reordered list still finds it.
93    const saved = await $.store.get('station').catch(() => undefined);
94    if (mine !== generation) return result;
95    const remembered = STATIONS.findIndex(({ name }) => name === saved);
96    if (remembered >= 0) station = remembered;
97    viewFrom = null;
98    active = e.isInteractive;
99    if (active) {
100      void radioWorker($, mine);
101      // The band may have been drawn before the session was ready.
102      $.ui.invalidate('ui.render');
103    }
104    return result;
105  }).catch(($, e, next) => next(e));
106
107  on('session.end', ($, e, next) => {
108    generation += 1;
109    active = false;
110    stopRadio();
111    wakeRadio?.();
112    return next(e);
113  });
114
115  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
116    const props = e.props ?? e;
117    const previous = await next(e);
118    if (!active || props.hasSurvey) return previous;
119    const { Box, Text, Button } = $.ui.resolve(e);
120    const columns = Math.max(1, Math.min(240, props.bodyColumns ?? e.viewport?.columns ?? 80));
121    // The band's [-] collapse mark sits at the end of its top row; when nothing
122    // is drawn above the radio, that row is ours, so we stop short of it.
123    const alone = !previous || previous.type === 'engine' || (previous.children ?? []).length === 0;
124    const width = Math.max(1, columns - (alone ? 4 : 0));
125    // A column of padding at each end keeps the buttons off the pane's edges.
126    const [left, right] = radioControls(Text, Button, width - 2, {
127      toggle: () => toggleRadio($), pick: index => pick($, index), scroll: direction => scroll($, direction) });
128    // The radio closes the band, below whatever other mods draw. The play button
129    // and ‹ hold the left end and › the right, so neither moves as the view pages.
130    return Box({ flexDirection: 'column', children: [previous,
131      Box({ key: 'radio-bar', flexDirection: 'row', justifyContent: 'space-between', width,
132        paddingLeft: 1, paddingRight: 1, children: [
133          Box({ flexDirection: 'row', gap: 2, children: left }), Box({ flexDirection: 'row', gap: 2, children: right })] })] });
134  }).catch(($, e, next) => next(e));
135}
136
137const ORANGE = '#d77757';
138
139// The radio row, laid out like a click wheel: play/pause, then the stations as
140// a carousel centred on the selected one.
141function radioControls(Text, Button, columns, { toggle, pick, scroll }) {
142  const playing = radio === 'on';
143  // A play/pause button, two columns in every state so nothing beside it moves.
144  const glyph = { off: '▶ ', error: '▶ ', tuning: '⋯ ', on: '❚❚' }[radio];
145  const tone = playing ? { color: ORANGE } : radio === 'tuning' ? { dimColor: true } : {};
146  const state = Button({ key: 'radio', plain: true, label: { off: 'Play', tuning: 'Tuning', on: 'Pause', error: 'Retry' }[radio],
147    onPress: toggle, children: Text({ ...tone, children: glyph }) });
148  const reason = radio === 'error' && (STATIONS[station].youtube ? 'Needs yt-dlp and ffplay' : 'Station unreachable');
149  // The right end only explains a station that can't play.
150  const status = reason ? (columns >= reason.length + 30 ? `⚠ ${reason}` : '⚠') : '';
151  const right = status ? [Text({ key: 'radio-status', dimColor: true, children: status })] : [];
152  // Room for station names: the button, both arrows, the right end, and the gaps.
153  const room = columns - 2 - 2 * 3 - status.length - 4;
154  let [from, to] = [0, STATIONS.length];
155  if (span(from, to) > room + 6 && viewFrom !== null) {
156    // Scrolled: fill forward from where the arrows left the view, and back
157    // from the end so the last page is full.
158    from = Math.max(0, Math.min(STATIONS.length - 1, viewFrom));
159    to = from + 1;
160    while (to < STATIONS.length && span(from, to + 1) <= room) to += 1;
161    while (to === STATIONS.length && from > 0 && span(from - 1, to) <= room) from -= 1;
162  } else if (span(from, to) > room + 6) {
163    // Grow out from the selected station a step at a time, left then right,
164    // so it stays near the centre as the list scrolls.
165    [from, to] = [station, station + 1];
166    for (let grew = true; grew;) {
167      grew = false;
168      if (from > 0 && span(from - 1, to) <= room) [from, grew] = [from - 1, true];
169      if (to < STATIONS.length && span(from, to + 1) <= room) [to, grew] = [to + 1, true];
170    }
171  }
172  const items = [state];
173  // An arrow with nothing past it still holds its place, so the carousel never shifts.
174  shown = { from, to, room };
175  const arrow = (key, label, direction, visible) => (visible
176    ? Button({ key, plain: true, label, onPress: () => scroll(direction), children: Text({ dimColor: true, children: label }) })
177    : Text({ children: ' ' }));
178  if (from > 0 || to < STATIONS.length) items.push(arrow('station-prev', '‹', -1, from > 0));
179  for (let index = from; index < to; index += 1) {
180    const chosen = index === station;
181    // The selected station stands out in bold, orange while it plays; the rest recede.
182    items.push(Button({ key: `station-${index}`, plain: true, label: STATIONS[index].name,
183      ...(chosen ? {} : { dimColor: true }), onPress: () => pick(index),
184      children: Text({ ...(chosen ? { bold: true, ...(playing ? { color: ORANGE } : {}) } : { dimColor: true }),
185        children: STATIONS[index].name }) }));
186  }
187  if (from > 0 || to < STATIONS.length) right.push(arrow('station-next', '›', 1, to < STATIONS.length));
188  return [items, right];
189}
190
191function toggleRadio($) {
192  if (!active) return;
193  if (radio === 'on' || radio === 'tuning') stopRadio();
194  else requestRadio();
195  $.ui.invalidate('ui.render');
196}
197
198// Picking a station plays it; picking the one playing stops it.
199function pick($, index) {
200  if (!active) return;
201  if (index === station) return toggleRadio($);
202  // The view holds still, so the row doesn't recentre under the click.
203  if (viewFrom === null && shown) viewFrom = shown.from;
204  stopRadio();
205  station = index;
206  void $.store.set('station', STATIONS[station].name).catch(() => {});
207  requestRadio();
208  $.ui.invalidate('ui.render');
209}
210
211// The arrows page through the stations without touching the selection.
212function scroll($, direction) {
213  if (!active || !shown) return;
214  const { from, to, room } = shown;
215  if (direction > 0) {
216    if (to >= STATIONS.length) return;
217    viewFrom = to;
218  } else {
219    if (from <= 0) return;
220    let start = from - 1;
221    while (start > 0 && span(start - 1, from) <= room) start -= 1;
222    viewFrom = start;
223  }
224  $.ui.invalidate('ui.render');
225}
226
227const span = (from, to) => STATIONS.slice(from, to).reduce((sum, { name }) => sum + name.length + 2, 0);
228
229function requestRadio() {
230  radioWanted = true;
231  radio = 'tuning';
232  wakeRadio?.();
233}
234
235async function radioWorker($, mine) {
236  while (mine === generation) {
237    if (!radioWanted) await new Promise(resolve => { wakeRadio = resolve; });
238    if (mine !== generation || !active) return;
239    if (!radioWanted) continue;
240    radioWanted = false;
241    await playRadio($);
242  }
243}
244
245async function playRadio($) {
246  const mine = ++radioGeneration;
247  const { url: page, youtube } = STATIONS[station];
248  radio = 'tuning';
249  try {
250    let url = page;
251    if (youtube) {
252      const resolved = await $.process.run(['yt-dlp', '-g', '-f', 'bestaudio/best', page], { timeoutMs: 30000 });
253      url = resolved.stdout.trim().split('\n').pop();
254      if (mine !== radioGeneration || !active) return;
255      if (resolved.exitCode !== 0 || !url) throw new Error('stream not resolved');
256    }
257    const stream = $.process.spawn({ argv: ['ffplay', '-nodisp', '-hide_banner', url] });
258    radioStream = stream;
259    radio = 'on';
260    $.ui.invalidate('ui.render');
261    // ffplay's stderr status line keeps this loop turning, so a stop is seen promptly.
262    for await (const _ of stream) {
263      if (radioStream !== stream) break;
264    }
265    if (mine !== radioGeneration) return;
266    radioStream = null;
267    radio = 'off';
268  } catch {
269    if (mine !== radioGeneration) return;
270    radioStream = null;
271    radio = 'error';
272  }
273  if (active) $.ui.invalidate('ui.render');
274}
275
276function stopRadio() {
277  radioGeneration += 1;
278  radioWanted = false;
279  const stream = radioStream;
280  radioStream = null;
281  radio = 'off';
282  // Ending the stream kills ffplay; the loop's own break covers a pending pull.
283  void stream?.return?.()?.catch?.(() => {});
284}
285