SLOPSHOPPER

null-radio

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

newpanecommandtoaststatusprocess
v0.1.0MITupdated 2026-10-10Nexus-nimdA/null-radio
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · null-radio
│ ┃ Null Radio ✕ › fix the failing auth test and add an audit log call │ ┃ ▚▞ NULL RADIO ○ off air · vol 70 │ ┃ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ⏺ Read(src/auth.ts) │ ┃ ── dial ───────────── ⎿ Read 6 lines │ ┃ 1: ch-nightride ⏺ Update(src/auth.ts) │ ┃ 2: ch-chillsynth ⎿ Added 2 lines, removed 1 line │ ┃ 3: ch-datawave ⏺ Bash(bun test) │ ┃ 4: ch-spacesynth ⎿ 3 pass, 1 fail │ ┃ 5: ch-darksynth │ ┃ 6: ch-ebsm ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 7: ch-groovesalad │ ┃ 8: ch-dronezone ✻ Worked for 42s · done 4:20 PM │ ┃ 9: ch-synphaera │ ┃ 0: ch-defcon › /nullradio │ ┃ d: vol-down u: vol-up ⎿ null-radio: Null Radio opened — pick a channel (hotkeys 1–0 when │ ┃ 1–0 tune · s stop · u/d vol · /radio reopens │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Null Radio
▚▞ NULL RADIO ○ off air · vol 70 ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ── dial ───────────── 1: ch-nightride 2: ch-chillsynth 3: ch-datawave 4: ch-spacesynth 5: ch-darksynth 6: ch-ebsm 7: ch-groovesalad 8: ch-dronezone 9: ch-synphaera 0: ch-defcon d: vol-down u: vol-up 1–0 tune · s stop · u/d vol · /radio reopens
README

NULL RADIO

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.

Null Radio on air

Install

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

The dial

#ChannelFlavorNetwork
1Nightride FMsynthwaveNightride
2ChillSynth FMchill synthwaveNightride
3DataWave FMdowntempo electronicsNightride
4SpaceSynth FMspace discoNightride
5DarkSynth FMdark / aggressiveNightride
6EBSMindustrial danceNightride
7Groove Saladambient beatsSomaFM
8Drone Zonedeep ambientSomaFM
9Synphaeraspace ambientSomaFM
0DEF CON Radiohacker electronicaSomaFM

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.

What it does — and provably doesn't

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:

  • Hooks: 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
  • Calls: $.process.spawn/$.process.run (the player + metadata polls), $.clock, $.state (6 own keys), $.store, $.ui.open/status/toast
  • Zero $.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.

Honest notes

  • The VU is cosmetic. ffplay exposes no level data; the bars animate while the stream plays and flatline when it stops. (A real one is on the list below.)
  • Stop actually stops — it kills the player process, it isn't a mute. The spawn stream is the child: a session ending or the mod reloading kills the player too, so there's never an orphan ffplay. If you were on air when a reload hit, playback auto-resumes.
  • Volume changes respawn the player (ffplay takes volume at launch only) — a sub-second gap, by design. Volume and last channel persist across sessions.
  • About /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.

On the list

  • A real, signal-driven VU (ffmpeg sidecar feeding the pane)
  • More default channels if people want them — suggestions welcome
  • Whatever the dial turns out to be missing once it's in your session

Support the stations

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.

Built with

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)

Source 2 files
hooks/register.tsx 494 lines
1// 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}
494
types/index.d.ts 29 lines
1export 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