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

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.
hooks/register.mjs 285 lines1// 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