Null Radio — a cyber-neon internet radio pane for Claude Code: ten-channel synthwave + ambient dial, now-playing, VU, played through a local ffplay

A cyber-neon internet radio that lives inside Claude Code. Ten-channel synthwave + ambient dial, now-playing, a VU, hotkeys — drawn as native harness UI (a mod on the Claude Code plugin-hooks API), with the audio played by a local ffplay the mod spawns and kills. All in a pane docked next to your conversation.

/plugin install null-radio --marketplace Nexus-nimdA/null-radio
Typed at the prompt of a terminal claude session (answer y to add the marketplace, pick a scope). Needs ffplay on your PATH — it ships with FFmpeg (winget install Gyan.FFmpeg / brew install ffmpeg / apt install ffmpeg). mpv works too, via the playerPath setting.
Then: /nullradio opens the dial (on most builds plain /radio works too — see Honest notes). Hotkeys while the pane is focused: 1–0 tune a channel, s stop, p play, u / d volume.
| # | Channel | Flavor | Network |
|---|---|---|---|
| 1 | Nightride FM | synthwave | Nightride |
| 2 | ChillSynth FM | chill synthwave | Nightride |
| 3 | DataWave FM | downtempo electronics | Nightride |
| 4 | SpaceSynth FM | space disco | Nightride |
| 5 | DarkSynth FM | dark / aggressive | Nightride |
| 6 | EBSM | industrial dance | Nightride |
| 7 | Groove Salad | ambient beats | SomaFM |
| 8 | Drone Zone | deep ambient | SomaFM |
| 9 | Synphaera | space ambient | SomaFM |
| 0 | DEF CON Radio | hacker electronica | SomaFM |
All stream URLs + now-playing endpoints verified live 2026-10-10.
Add your own channels — the extraChannels setting takes a JSON array; any direct stream URL becomes a dial entry with its own hotkey:
[{"id":"mystation","name":"My Station","url":"https://…","genre":"…"}]
Now-playing per entry: "meta":{"kind":"somafm"|"nightride","station":"…"} for those networks, {"kind":"icy"} (default) to read the stream's own ICY tags, {"kind":"none"} to skip.
claude plugin validate reads a mod's source and prints everything it hooks and calls, before anything runs. This mod's manifest, as validate prints it:
session.start, command.run{command=nullradio} (answers its own command), command.run{command=radio} (pass-through guarded), ui.render{component=Pane, requestId=radio} — its own pane only$.process.spawn/$.process.run (the player + metadata polls), $.clock, $.state (6 own keys), $.store, $.ui.open/status/toast$.fs. Zero $.env. No file reads, no environment variables.Network shape: public radio audio comes in through the player's own stream connection; the only other network touches are read-only now-playing polls (curl against Nightride's public SSE feed and SomaFM's public songs JSON; ffprobe ICY tags for custom channels). Nothing from your session is ever sent anywhere. No analytics, no phone-home, no version check.
/radio: Anthropic ships its own "Claude FM" — a built-in /radio lofi command on a gradual rollout. On builds where it's present, this mod's /radio registration is refused and the command passes through to the built-in untouched; /nullradio works everywhere, always.The music is the stations' gift, not this mod's. SomaFM is listener-supported — somafm.com/support — and Nightride FM runs on its community. If a channel soundtracks your work, send them something.
ffplay (FFmpeg — external, user-installed, spawned by argv, never vendored), curl (OS-shipped) for metadata polls, and the Claude Code plugin-hooks API. Zero vendored third-party code; the test suite runs on claude plugin test.
MIT © Dean Cristofori (Nexus-nimdA)
hooks/register.tsx 494 lines1// Null Radio — a cyber-neon internet radio pane for Claude Code: channel
2// dial (synthwave + ambient, hotkeys 1–0), now-playing, a VU, volume,
3// stop — the audio itself comes from a LOCAL player the mod spawns and
4// kills (ffplay by default; mpv works via the playerPath config).
5// /nullradio always opens the dial; /radio too, on builds where
6// Anthropic's Claude FM (a gradual-rollout built-in) hasn't claimed it.
7//
8// Network shape (the house rule): public radio audio comes IN through the
9// player's own stream connection; the only other network touches are
10// read-only now-playing polls (curl against the stations' public metadata
11// endpoints, ffprobe ICY tags for custom channels). Nothing of the session
12// ever goes out. Every default channel URL and both metadata endpoints
13// were verified live 2026-10-10 from this machine (ffprobe/curl), and the
14// exact ffplay argv below was test-fired for 7s against Nightride first.
15//
16// Lifecycle: the spawn stream IS the child (its loop's end kills it), so a
17// hot reload or session end kills the player — never an orphan ffplay.
18// session.start auto-resumes when $.state says we were on air, and volume +
19// last channel persist across sessions via $.store.
20//
21// The VU is cosmetic — ffplay exposes no level data; it animates while
22// playing (beat-biased random walk), flatlines when stopped.
23//
24// Hooks swallow their own errors: a bad fetch or a dead player degrades the
25// pane (and says so), never the session.
26import { atom, read, update } from 'claude-code'
27import type { Register } from 'claude-code'
28
29import type { RadioChannel, RadioNowPlaying } from '../types'
30
31const PANE = 'radio'
32const VU_COLS = 16
33const VU_MS = 400
34const META_MS = 20_000
35const VOL_STEP = 10
36
37const current = atom({ plugin: 'null-radio', key: 'current' } as const, null)
38const isPlaying = atom({ plugin: 'null-radio', key: 'isPlaying' } as const, false)
39const volume = atom({ plugin: 'null-radio', key: 'volume' } as const, 70)
40const nowPlaying = atom({ plugin: 'null-radio', key: 'nowPlaying' } as const, null)
41const vuTick = atom({ plugin: 'null-radio', key: 'vuTick' } as const, 0)
42const lastError = atom({ plugin: 'null-radio', key: 'lastError' } as const, null)
43
44const NEON = {
45 magenta: '#ff2fd6',
46 cyan: '#00e5ff',
47 green: '#39ff14',
48 yellow: '#ffe600',
49 red: '#ff3355',
50 violet: '#b86bff',
51} as const
52
53// The default dial — every URL verified live 2026-10-10.
54const CHANNELS: RadioChannel[] = [
55 { id: 'nightride', name: 'Nightride FM', genre: 'synthwave', url: 'https://stream.nightride.fm/nightride.ogg', meta: { kind: 'nightride', station: 'nightride' } },
56 { id: 'chillsynth', name: 'ChillSynth FM', genre: 'chill synthwave', url: 'https://stream.nightride.fm/chillsynth.ogg', meta: { kind: 'nightride', station: 'chillsynth' } },
57 { id: 'datawave', name: 'DataWave FM', genre: 'downtempo electronics', url: 'https://stream.nightride.fm/datawave.ogg', meta: { kind: 'nightride', station: 'datawave' } },
58 { id: 'spacesynth', name: 'SpaceSynth FM', genre: 'space disco', url: 'https://stream.nightride.fm/spacesynth.ogg', meta: { kind: 'nightride', station: 'spacesynth' } },
59 { id: 'darksynth', name: 'DarkSynth FM', genre: 'dark / aggressive', url: 'https://stream.nightride.fm/darksynth.ogg', meta: { kind: 'nightride', station: 'darksynth' } },
60 { id: 'ebsm', name: 'EBSM', genre: 'industrial dance', url: 'https://stream.nightride.fm/ebsm.ogg', meta: { kind: 'nightride', station: 'ebsm' } },
61 { id: 'groovesalad', name: 'Groove Salad', genre: 'ambient beats', url: 'https://ice1.somafm.com/groovesalad-256-mp3', meta: { kind: 'somafm', station: 'groovesalad' } },
62 { id: 'dronezone', name: 'Drone Zone', genre: 'deep ambient', url: 'https://ice1.somafm.com/dronezone-256-mp3', meta: { kind: 'somafm', station: 'dronezone' } },
63 { id: 'synphaera', name: 'Synphaera', genre: 'space ambient', url: 'https://ice1.somafm.com/synphaera-256-mp3', meta: { kind: 'somafm', station: 'synphaera' } },
64 { id: 'defcon', name: 'DEF CON Radio', genre: 'hacker electronica', url: 'https://ice1.somafm.com/defcon-256-mp3', meta: { kind: 'somafm', station: 'defcon' } },
65]
66
67// Hotkeys: digits for the first ten rows, then letters — s/p/u/d stay
68// reserved for stop / play / vol up / vol down.
69const HOTKEYS = '1234567890abcefghijklmnoqrtvwxyz'
70const hotkeyFor = (i: number): string | undefined => HOTKEYS[i]
71
72// Module state (lost on hot reload by design — the child dies with the
73// module, and $.state carries what matters across).
74let channels: RadioChannel[] = [...CHANNELS]
75let playerPath = 'ffplay'
76let playerGen = 0
77let playerStream: AsyncGenerator<{ stream: string; text: string }, unknown> | null = null
78let timersStarted = false
79let hasRadioCmd = false // /radio registered too? (free unless Claude FM ships here)
80let vuLevels: number[] = Array.from({ length: VU_COLS }, () => 0.05)
81let vuBeat = 0
82
83const channelById = (id: string | null): RadioChannel | undefined =>
84 id === null ? undefined : channels.find(c => c.id === id)
85
86const clip = (s: string, w: number) => (s.length > w ? s.slice(0, Math.max(0, w - 1)) + '…' : s.padEnd(w))
87
88const argvFor = (url: string, vol: number): string[] => {
89 const p = playerPath.toLowerCase()
90 if (p.includes('mpv')) return [playerPath, '--no-video', '--really-quiet', `--volume=${vol}`, url]
91 // ffplay: flags test-fired 2026-10-10 — no window, quiet, auto-reconnect.
92 return [
93 playerPath, '-hide_banner', '-nodisp', '-loglevel', 'error', '-nostats',
94 '-volume', String(vol),
95 '-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5',
96 url,
97 ]
98}
99
100// ---- now-playing parsers (each endpoint's shape verified live 2026-10-10) ----
101
102// Nightride serves one SSE feed for ALL its stations:
103// data: [{"station":"nightride","title":"...","artist":"..."}]
104// A ~4s curl grab catches the full roster's current tracks.
105const parseNightride = (stdout: string, station: string): RadioNowPlaying | null => {
106 let found: RadioNowPlaying | null = null
107 for (const line of stdout.split('\n')) {
108 if (!line.startsWith('data:')) continue
109 try {
110 const rows = JSON.parse(line.slice(5).trim()) as Array<{ station?: string; title?: string; artist?: string }>
111 if (!Array.isArray(rows)) continue
112 for (const r of rows) {
113 if (r.station === station && (r.title ?? '') !== '') found = { artist: r.artist ?? '', title: r.title ?? '' }
114 }
115 } catch {}
116 }
117 return found
118}
119
120// SomaFM: https://somafm.com/songs/<station>.json — songs[0] is current.
121const parseSomafm = (stdout: string): RadioNowPlaying | null => {
122 try {
123 const j = JSON.parse(stdout) as { songs?: Array<{ title?: string; artist?: string }> }
124 const s = j.songs?.[0]
125 if (s === undefined || ((s.title ?? '') === '' && (s.artist ?? '') === '')) return null
126 return { artist: s.artist ?? '', title: s.title ?? '' }
127 } catch {
128 return null
129 }
130}
131
132// ICY fallback for custom channels: ffprobe reads StreamTitle off the
133// stream itself ("Artist - Title" by convention).
134const parseIcy = (stdout: string): RadioNowPlaying | null => {
135 const t = stdout.split('\n')[0]?.trim() ?? ''
136 if (t === '') return null
137 const cut = t.indexOf(' - ')
138 return cut < 0 ? { artist: '', title: t } : { artist: t.slice(0, cut), title: t.slice(cut + 3) }
139}
140
141async function fetchNowPlaying($: any, ch: RadioChannel) {
142 try {
143 let np: RadioNowPlaying | null = null
144 if (ch.meta.kind === 'nightride') {
145 // curl exits 28 when --max-time cuts the SSE feed — the data is in
146 // stdout regardless, so the exit code is ignored on purpose.
147 const r = await $.process.run(['curl', '-s', '-N', '--max-time', '4', 'https://nightride.fm/meta'], { timeoutMs: 9000 })
148 np = parseNightride(String(r.stdout ?? ''), ch.meta.station)
149 } else if (ch.meta.kind === 'somafm') {
150 const r = await $.process.run(['curl', '-s', '--max-time', '6', `https://somafm.com/songs/${ch.meta.station}.json`], { timeoutMs: 9000 })
151 np = parseSomafm(String(r.stdout ?? ''))
152 } else if (ch.meta.kind === 'icy') {
153 const r = await $.process.run(
154 ['ffprobe', '-v', 'error', '-rw_timeout', '6000000', '-show_entries', 'format_tags=StreamTitle', '-of', 'default=noprint_wrappers=1:nokey=1', ch.url],
155 { timeoutMs: 12000 },
156 )
157 np = parseIcy(String(r.stdout ?? ''))
158 } else {
159 return
160 }
161 if (np === null) return
162 // The dial may have moved while the fetch ran.
163 if ((await read($, current)) !== ch.id || !(await read($, isPlaying))) return
164 const prev = (await read($, nowPlaying)) as RadioNowPlaying | null
165 if (prev !== null && prev.artist === np.artist && prev.title === np.title) return
166 await update($, nowPlaying, () => np)
167 try {
168 const line = np.artist === '' ? np.title : `${np.artist} — ${np.title}`
169 $.ui.status(`♪ ${clip(line, 70).trimEnd()} · ${ch.name}`)
170 } catch {}
171 } catch {}
172}
173
174// ---- the player itself ----
175
176function stopProcess() {
177 playerGen++ // marks any running drain loop as "replaced on purpose"
178 const s = playerStream
179 playerStream = null
180 if (s !== null) {
181 try {
182 void (s.return(undefined as never) as Promise<unknown>).catch(() => {})
183 } catch {}
184 }
185}
186
187async function drain($: any, stream: AsyncGenerator<{ stream: string; text: string }, unknown>, g: number, ch: RadioChannel) {
188 let tail = ''
189 let failed = ''
190 try {
191 for await (const chunk of stream) {
192 if (chunk.stream === 'stderr') tail = (tail + chunk.text).slice(-300)
193 // Pace the pulls: ffplay is near-silent at -loglevel error, so this
194 // loop normally parks on the first pull until the child exits.
195 try {
196 await $.clock.sleep(200)
197 } catch {}
198 }
199 } catch (err) {
200 failed = String(err) // the child could not start (first pull rejects)
201 }
202 if (playerGen !== g) return // we stopped or retuned it ourselves
203 playerStream = null
204 try {
205 const why = (tail.trim() !== '' ? tail.trim() : failed !== '' ? failed : 'player exited').slice(-160)
206 await update($, isPlaying, () => false)
207 await update($, lastError, () => why)
208 $.ui.status(undefined)
209 $.ui.toast(`📻 ${ch.name} stopped — ${why.slice(0, 90)}`)
210 } catch {}
211}
212
213async function startPlayback($: any, ch: RadioChannel) {
214 try {
215 if ((await read($, isPlaying)) && (await read($, current)) === ch.id && playerStream !== null) return
216 stopProcess()
217 const g = ++playerGen
218 const vol = (await read($, volume)) as number
219 await update($, current, () => ch.id)
220 await update($, isPlaying, () => true)
221 await update($, lastError, () => null)
222 await update($, nowPlaying, () => null)
223 try {
224 await $.store.set('lastChannel', ch.id)
225 } catch {}
226 try {
227 $.ui.status(`♪ ${ch.name}`)
228 } catch {}
229 const stream = $.process.spawn({ argv: argvFor(ch.url, vol) })
230 playerStream = stream
231 void drain($, stream, g, ch)
232 void fetchNowPlaying($, ch)
233 } catch {}
234}
235
236async function stopPlayback($: any) {
237 try {
238 stopProcess()
239 await update($, isPlaying, () => false)
240 await update($, nowPlaying, () => null)
241 try {
242 $.ui.status(undefined)
243 } catch {}
244 } catch {}
245}
246
247// Volume lands by respawning the player at the new level (ffplay takes
248// volume only at launch) — a sub-second gap in the audio, by design.
249async function setVolume($: any, delta: number) {
250 try {
251 let v = 70
252 await update($, volume, old => {
253 v = Math.max(0, Math.min(100, (old as number) + delta))
254 return v
255 })
256 try {
257 await $.store.set('volume', v)
258 } catch {}
259 if ((await read($, isPlaying)) === true) {
260 const ch = channelById((await read($, current)) as string | null)
261 if (ch !== undefined) {
262 playerGen++ // retune on purpose: keep drain quiet about the swap
263 await startPlaybackFresh($, ch)
264 }
265 }
266 } catch {}
267}
268
269// startPlayback skips when the same channel is already on air; a volume
270// change needs the respawn anyway, so it goes around that guard.
271async function startPlaybackFresh($: any, ch: RadioChannel) {
272 stopProcess()
273 const g = ++playerGen
274 const vol = (await read($, volume)) as number
275 const stream = $.process.spawn({ argv: argvFor(ch.url, vol) })
276 playerStream = stream
277 void drain($, stream, g, ch)
278}
279
280async function openDial($: any) {
281 await $.ui.open({ id: PANE, title: 'Null Radio' })
282 const playing = (await read($, isPlaying)) === true
283 const ch = channelById((await read($, current)) as string | null)
284 return {
285 text:
286 playing && ch !== undefined
287 ? `Null Radio opened — on air: ${ch.name}.`
288 : 'Null Radio opened — pick a channel (hotkeys 1–0 when the pane is focused).',
289 }
290}
291
292// ---- cosmetic VU ----
293
294const VU_GLYPHS = '▁▂▃▄▅▆▇█'
295const vuGlyph = (l: number) => VU_GLYPHS[Math.min(7, Math.max(0, Math.floor(l * 8)))] ?? '▁'
296const vuColor = (l: number) => (l < 0.45 ? NEON.cyan : l < 0.75 ? NEON.violet : NEON.magenta)
297
298function vuAdvance() {
299 vuBeat = (vuBeat + 1) % 8
300 const pulse = vuBeat < 2 ? 0.3 : 0
301 const half = (VU_COLS - 1) / 2
302 for (let i = 0; i < VU_COLS; i++) {
303 const centerBias = (1 - Math.abs(i - half) / half) * 0.3
304 const target = Math.random() * 0.5 + pulse + centerBias
305 vuLevels[i] = Math.max(0.04, Math.min(1, (vuLevels[i] ?? 0.05) * 0.55 + target * 0.45))
306 }
307}
308
309// ---- wiring ----
310
311export const register: Register = (on, options) => {
312 playerPath = ((options as { playerPath?: string } | undefined)?.playerPath ?? 'ffplay').trim() || 'ffplay'
313 channels = [...CHANNELS]
314 try {
315 const raw = (options as { extraChannels?: string } | undefined)?.extraChannels
316 if (typeof raw === 'string' && raw.trim() !== '') {
317 const rows = JSON.parse(raw) as Array<Partial<RadioChannel>>
318 if (Array.isArray(rows)) {
319 for (const r of rows) {
320 if (typeof r.id === 'string' && typeof r.name === 'string' && typeof r.url === 'string' && channelById(r.id) === undefined) {
321 channels.push({
322 id: r.id,
323 name: r.name,
324 url: r.url,
325 genre: typeof r.genre === 'string' ? r.genre : 'custom',
326 meta: r.meta !== undefined && typeof r.meta === 'object' ? (r.meta as RadioChannel['meta']) : { kind: 'icy' },
327 })
328 }
329 }
330 }
331 }
332 } catch {}
333
334 on('session.start', async ($, e, next) => {
335 try {
336 // /nullradio is the stable name, registered always; /radio is the
337 // convenience alias, taken where Anthropic's Claude FM built-in (a
338 // gradual rollout) hasn't claimed it — refused there, by design.
339 try {
340 await $.command.register({ name: 'nullradio', description: 'Null Radio: open the cyber-neon dial' })
341 } catch {}
342 try {
343 await $.command.register({ name: 'radio', description: 'Null Radio: open the cyber-neon dial' })
344 hasRadioCmd = true
345 } catch {}
346 try {
347 const sv = await $.store.get('volume')
348 if (typeof sv === 'number' && Number.isFinite(sv)) await update($, volume, () => Math.max(0, Math.min(100, Math.round(sv))))
349 const sc = await $.store.get('lastChannel')
350 if (typeof sc === 'string' && channelById(sc) !== undefined) await update($, current, c => c ?? sc)
351 } catch {}
352 // A hot reload killed the child with the old module; state remembers
353 // we were on air, so the music comes back by itself.
354 if ((await read($, isPlaying)) === true) {
355 const ch = channelById((await read($, current)) as string | null)
356 if (ch !== undefined) await startPlayback($, ch)
357 else await update($, isPlaying, () => false)
358 }
359 if (!timersStarted) {
360 timersStarted = true
361 $.clock.every(VU_MS, async () => {
362 try {
363 if ((await read($, isPlaying)) !== true) return
364 vuAdvance()
365 await update($, vuTick, t => ((t as number) + 1) % 1_000_000)
366 } catch {}
367 })
368 $.clock.every(META_MS, async () => {
369 try {
370 if ((await read($, isPlaying)) !== true) return
371 const ch = channelById((await read($, current)) as string | null)
372 if (ch !== undefined) await fetchNowPlaying($, ch)
373 } catch {}
374 })
375 }
376 } catch {}
377 return next(e)
378 })
379
380 // /nullradio is always ours; /radio answers only when this module owns
381 // it, and otherwise passes down the chain (a built-in Claude FM) untouched.
382 on('command.run', { command: 'nullradio' }, async $ => openDial($))
383 on('command.run', { command: 'radio' }, async ($, e, next) => (hasRadioCmd ? openDial($) : next(e))).catch(($, e, next) => next(e))
384
385 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
386 const { Box, Text, Button } = $.ui.resolve(e)
387 try {
388 const playing = (await read($, isPlaying)) === true
389 const curId = (await read($, current)) as string | null
390 const vol = (await read($, volume)) as number
391 const np = (await read($, nowPlaying)) as RadioNowPlaying | null
392 const err = (await read($, lastError)) as string | null
393 await read($, vuTick) // subscribes the pane to the VU animation clock
394
395 const body = e.props.bodyColumns ?? e.viewport?.columns ?? 60
396 const nameW = Math.max(12, Math.min(18, body - 30))
397 const genreW = Math.max(8, Math.min(22, body - nameW - 10))
398
399 // One Text per same-color run keeps the tree small.
400 const vuRuns: Array<{ text: string; color: string }> = []
401 for (const l of vuLevels) {
402 const color = vuColor(l)
403 const g = vuGlyph(l)
404 const last = vuRuns[vuRuns.length - 1]
405 if (last !== undefined && last.color === color) last.text += g
406 else vuRuns.push({ text: g, color })
407 }
408
409 return (
410 <Box flexDirection="column">
411 <Box>
412 <Text color={NEON.magenta} bold>
413 ▚▞ NULL RADIO{' '}
414 </Text>
415 {playing ? <Text color={NEON.green}>● ON AIR</Text> : <Text dimColor>○ off air</Text>}
416 <Text dimColor> · vol {String(vol)}</Text>
417 </Box>
418 <Box>
419 {playing ? (
420 vuRuns.map((r, i) => (
421 <Text key={'vu' + String(i)} color={r.color}>
422 {r.text}
423 </Text>
424 ))
425 ) : (
426 <Text dimColor>{'▁'.repeat(VU_COLS)}</Text>
427 )}
428 </Box>
429 {np !== null ? (
430 <Box>
431 <Text color={NEON.cyan} wrap="truncate-end">
432 ♪ {np.title}
433 </Text>
434 {np.artist !== '' ? <Text color={NEON.violet} wrap="truncate-end">{' '}{np.artist}</Text> : null}
435 </Box>
436 ) : playing ? (
437 <Text dimColor>♪ tuning…</Text>
438 ) : null}
439 <Text dimColor>── dial ─────────────</Text>
440 {channels.map((ch, i) => {
441 const isCur = curId === ch.id
442 return (
443 <Box key={ch.id}>
444 <Button key={'ch-' + ch.id} plain hotkey={hotkeyFor(i)} onPress={() => void startPlayback($, ch)}>
445 {isCur && playing ? (
446 <Text color={NEON.magenta} bold>
447 ▸ {clip(ch.name, nameW)}
448 </Text>
449 ) : isCur ? (
450 <Text color={NEON.violet}>▹ {clip(ch.name, nameW)}</Text>
451 ) : (
452 <Text color={NEON.cyan}>{' '}{clip(ch.name, nameW)}</Text>
453 )}
454 <Text dimColor> {clip(ch.genre, genreW)}</Text>
455 </Button>
456 </Box>
457 )
458 })}
459 <Box>
460 {playing ? (
461 <Button key="stop" plain hotkey="s" onPress={() => void stopPlayback($)}>
462 <Text color={NEON.red}>■ stop</Text>
463 </Button>
464 ) : curId !== null ? (
465 <Button key="play" plain hotkey="p" onPress={() => {
466 const ch = channelById(curId)
467 if (ch !== undefined) void startPlayback($, ch)
468 }}>
469 <Text color={NEON.green}>▶ play</Text>
470 </Button>
471 ) : null}
472 <Text> </Text>
473 <Button key="vol-down" plain hotkey="d" onPress={() => void setVolume($, -VOL_STEP)}>
474 <Text color={NEON.yellow}>vol−</Text>
475 </Button>
476 <Text> </Text>
477 <Button key="vol-up" plain hotkey="u" onPress={() => void setVolume($, VOL_STEP)}>
478 <Text color={NEON.yellow}>vol+</Text>
479 </Button>
480 </Box>
481 {err !== null ? (
482 <Text color={NEON.red} wrap="truncate-end">
483 ⚠ {err}
484 </Text>
485 ) : null}
486 <Text dimColor>1–0 tune · s stop · u/d vol · /{hasRadioCmd ? 'radio' : 'nullradio'} reopens</Text>
487 </Box>
488 )
489 } catch {
490 return <Text dimColor>null-radio: render error (the pane will recover next redraw)</Text>
491 }
492 })
493}
494types/index.d.ts 29 lines1export type RadioNowPlaying = { artist: string; title: string }
2
3export type RadioMeta =
4 | { kind: 'nightride'; station: string }
5 | { kind: 'somafm'; station: string }
6 | { kind: 'icy' }
7 | { kind: 'none' }
8
9export type RadioChannel = {
10 id: string
11 name: string
12 genre: string
13 url: string
14 meta: RadioMeta
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'null-radio': {
20 current: string | null
21 isPlaying: boolean
22 volume: number
23 nowPlaying: RadioNowPlaying | null
24 vuTick: number
25 lastError: string | null
26 }
27 }
28}
29