SLOPSHOPPER

lofi-radio

Claude FM player: a pane drawn like the stream with the current track, /lofi, volume, a mini video window; audio via yt-dlp + ffplay

newpanebandcommandprocesstimer
v0.3.0MITupdated 2026-10-09alkanalperen/lofi-radio
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lofi-radio
│ ┃ Claude FM ✕ › fix the failing auth test and add an audit log call │ ┃ ♫ Claude FM │ ┃ music for thinking and building ⏺ Read(src/auth.ts) │ ┃ .:. .::. .:. ⎿ Read 6 lines │ ┃ .:::::. .:::::::::. .:. .:::::. ⏺ Update(src/auth.ts) │ ┃ ~ ~ ~ ~ ~ ~ ~ \__/ ~ ~ ~ ~ ~ ~ ~ ~ ⎿ Added 2 lines, removed 1 line │ ┃ ● TUNING IN… · vol ▮▮▮▮▮▮▯▯▯▯ ⏺ Bash(bun test) │ ┃ [ ■ Cancel ] [ − ] [ + ] [ ▣ Mini window ] Y ⎿ 3 pass, 1 fail │ ┃ unofficial, not affiliated with Anthropic │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /lofi │ ⎿ lofi-radio: Claude FM tuning in… │ │ ♪ Claude FM tuning in… [ Cancel ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
♪ Claude FM tuning in… [ Cancel ] ⟨Claude Code's own drawing⟩
Pane · Claude FM
♫ Claude FM music for thinking and building .:. .::. .:. .:::::. .:::::::::. .:. .:::::. ~ ~ ~ ~ ~ ~ ~ \__/ ~ ~ ~ ~ ~ ~ ~ ~ ● TUNING IN… · vol ▮▮▮▮▮▮▯▯▯▯ [ ■ Cancel ] [ − ] [ + ] [ ▣ Mini window ] YouTube ↗ https:/ unofficial, not affiliated with Anthropic
README

lofi-radio

Claude FM, Anthropic's 24/7 lo-fi stream, inside Claude Code.

The player pane: a dotted night lake, Clawd fishing from a boat, the current track and a live bar

A Claude Code mod (a plugin of function hooks) that adds:

  • a button above the prompt that starts the radio,
  • a player pane drawn like the stream: dotted mountains, a pine line, drifting water and Clawd fishing from his boat,
  • the current track, read off the stream itself,
  • a listening timer and a small catch: one fish for every Claude turn that finishes while the radio plays,
  • volume control and a small always-on-top video window,
  • /lofi to open the player and play, /lofi stop to stop.

By default it plays audio only, so there is no browser tab.

Unofficial, fan-made mod. Not affiliated with or endorsed by Anthropic.

Requirements

  • macOS (it uses Homebrew's paths, pkill, and macOS Vision for the track name)
  • brew install yt-dlp ffmpeg deno (yt-dlp needs a JavaScript runtime such as Deno for full YouTube support)
  • Keep yt-dlp current with brew upgrade yt-dlp. YouTube changes often break older versions.
  • For the track name: the Xcode Command Line Tools (xcode-select --install). Without them the radio still plays and the pane shows the stream's tagline instead.
  • Claude Code with plugin hooks (mods)

Install

In a claude session in the terminal:

/plugin install lofi-radio --marketplace alkanalperen/lofi-radio

Or from the shell:

claude plugin marketplace add alkanalperen/lofi-radio
claude plugin install lofi-radio@lofi-radio

If the button above the prompt does not show up, restart Claude Code.

Use

  1. Click ♪ Play Claude FM above the prompt, or run /lofi.
  2. In the pane: ▶ Play / ■ Stop (p), − / + for volume (j / k), ▣ Mini window (m) for the video, and a link to the stream on YouTube.
  3. Run /lofi stop to stop.

The player follows Claude Code's language setting, then the system locale. Set the plugin's language option to en or tr to choose.

How it works

yt-dlp resolves https://clau.de/radio (the address /radio opens) to the live HLS stream. The stream has no audio-only format, so it picks 360p, the smallest format with AAC-LC audio, and ffplay plays it with -nodisp. A resolved address goes stale within minutes, so every start resolves a fresh one, and a stream that stops getting segments is resolved again by itself. Changing the volume or the window restarts ffplay; the old player keeps going until the new one is ready.

The track name exists only in the video. While the radio plays, a small macOS Vision helper (compiled once into ~/.cache/lofi-radio) reads the stream's now-playing box: one frame every 90 seconds to notice a change, and a 28-second read to stitch the scrolling name when it does. That costs about 20 MB an hour.

Credits

The pixel Clawd sprite comes from johnnyvizz's savvy-progress (claude-kit, MIT) by way of the agent-deck mod. Claude, Claude FM and the Clawd character belong to Anthropic.

The music on Claude FM is made by independent artists. The pane names the track; earwitness.fyi also reads the ticker and credits the artists.

License

MIT for the code. See LICENSE and NOTICE.

Türkçe

Claude FM'i Claude Code'un içinde çalan bir mod. Prompt'un üstündeki ♪ Claude FM çal butonuna bas veya /lofi yaz. Panelde çalan şarkı, çal/durdur, ses ve mini pencere var. Arayüz Claude Code'un dil ayarını izler; eklentinin language ayarıyla tr seçebilirsin. Kurulum için önce brew install yt-dlp ffmpeg deno, sonra yukarıdaki /plugin install satırı. Resmi değil, Anthropic ile bağı yok.

Source 2 files
hooks/register.tsx 919 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register, Timer } from 'claude-code'
3
4import type { Pond, Radio } from '../types'
5
6// The address /radio opens; it redirects to whatever the current YouTube live id is.
7export const STREAM_URL = 'https://clau.de/radio'
8// The stream has no audio-only format; 360p is the smallest one with AAC-LC audio.
9const FORMAT = 'worst[height>=360]/worst'
10// 720p for reading the now-playing box: lower resolutions misread it.
11const OCR_FORMAT = '95/best'
12const TITLE = 'Claude FM'
13// pkill -f matches the joined argv, so ffplay's window title marks our player.
14// No leading dash, or pkill would read the pattern as a flag.
15const MARK = `window_title ${TITLE}`
16// The desktop app's PATH may miss Homebrew, so each tool is tried there too.
17const BIN_DIRS = ['', '/opt/homebrew/bin/', '/usr/local/bin/']
18const PANE = 'claude-fm'
19const CHECK_MS = 90_000
20const BACKOFF_MS = 300_000
21// A resolved HLS address goes stale within minutes and ffplay then retries its 403s
22// forever, so a stalled stream is resolved afresh this many times before giving up.
23const MAX_RESTARTS = 2
24const HEALTHY_MS = 120_000
25// ffplay logs this when it gives up on a segment. A lone 403 is routine (keepalive
26// retries recover); a run of skipped segments means no sound and a stale address.
27const SKIP = /failed too many times, skipping/g
28const STALL_SKIPS = 3
29const STALL_WINDOW_MS = 30_000
30// ffplay traps SIGINT and SIGTERM and exits 123, so another session's pkill is a stop, not a failure.
31const FFPLAY_KILLED = 123
32const NP_SOURCE = 'bin/nowplaying.swift'
33const NP_VERSION = 1
34// The Command Line Tools' own compiler; /usr/bin/swiftc is a shim that offers to install them.
35const CLT_SWIFTC = '/Library/Developer/CommandLineTools/usr/bin/swiftc'
36
37// --- language: the `language` option, else Claude Code's `language` setting, else the
38// process locale; English unless one of them says Turkish.
39
40type Lang = 'en' | 'tr'
41
42const EN = {
43  live: 'LIVE',
44  tuning: 'TUNING IN…',
45  off: 'OFF',
46  error: 'NO SIGNAL',
47  boxTuning: 'tuning in…',
48  boxOff: 'Claude FM · off',
49  boxError: 'no signal',
50  tagline: 'music for thinking and building',
51  minutes: (m: number) => (m >= 60 ? `${Math.floor(m / 60)} h ${m % 60} min` : `${m} min`),
52  volume: (v: number) => `vol ${v}%`,
53  volumeShort: 'vol',
54  fishToday: (n: number) => `${n} fish today`,
55  play: '▶ Play',
56  stop: '■ Stop',
57  cancel: '■ Cancel',
58  retry: '↻ Retry',
59  mini: '▣ Mini window',
60  closeMini: '▣ Close window',
61  unofficial: 'unofficial, not affiliated with Anthropic',
62  bandPlay: '♪ Play Claude FM',
63  bandTuning: '♪ Claude FM tuning in…',
64  bandCancel: 'Cancel',
65  bandRetry: 'Retry',
66  player: 'Player',
67  stopped: 'Claude FM stopped.',
68  playing: 'Claude FM is playing.',
69  starting: 'Claude FM tuning in…',
70  missing: (tool: string) => `${tool} missing: brew install ${tool}`,
71  notFound: 'stream not found',
72  ffplayStopped: 'ffplay stopped',
73  stalled: 'stream address expired',
74  command: 'Open the Claude FM player and play (/lofi stop to stop)',
75}
76type Strings = typeof EN
77
78const TR: Strings = {
79  live: 'CANLI',
80  tuning: 'AÇILIYOR…',
81  off: 'KAPALI',
82  error: 'BAĞLANAMADI',
83  boxTuning: 'ayar çekiliyor…',
84  boxOff: 'Claude FM · kapalı',
85  boxError: 'bağlanamadı',
86  tagline: 'music for thinking and building',
87  minutes: m => (m >= 60 ? `${Math.floor(m / 60)} sa ${m % 60} dk` : `${m} dk`),
88  volume: v => `ses %${v}`,
89  volumeShort: 'ses',
90  fishToday: n => `bugün ${n} balık`,
91  play: '▶ Çal',
92  stop: '■ Durdur',
93  cancel: '■ İptal',
94  retry: '↻ Tekrar dene',
95  mini: '▣ Mini pencere',
96  closeMini: '▣ Pencereyi kapat',
97  unofficial: 'resmi değil, Anthropic ile bağı yok',
98  bandPlay: '♪ Claude FM çal',
99  bandTuning: '♪ Claude FM açılıyor…',
100  bandCancel: 'İptal',
101  bandRetry: 'Tekrar dene',
102  player: 'Çalar',
103  stopped: 'Claude FM durdu.',
104  playing: 'Claude FM çalıyor.',
105  starting: 'Claude FM açılıyor…',
106  missing: tool => `${tool} yok: brew install ${tool}`,
107  notFound: 'yayın bulunamadı',
108  ffplayStopped: 'ffplay durdu',
109  stalled: 'yayın adresi eskidi',
110  command: 'Claude FM çaları aç ve çal (/lofi stop durdurur)',
111}
112
113export const STRINGS: Record<Lang, Strings> = { en: EN, tr: TR }
114
115// Module scope is fine: session.start sets it again on every (re)load.
116let lang: Lang = 'en'
117const t = () => STRINGS[lang]
118
119const isTurkish = (v: unknown) => typeof v === 'string' && /^(tr([-_.]|$)|turk|türk)/i.test(v.trim())
120
121async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
122  if (option === 'en' || option === 'tr') return option
123  try {
124    const settings = (await $.settings.read()) as Record<string, unknown>
125    if (typeof settings.language === 'string' && settings.language.trim()) return isTurkish(settings.language) ? 'tr' : 'en'
126  } catch {
127    // No settings: fall through to the locale.
128  }
129  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
130  return isTurkish(locale) ? 'tr' : 'en'
131}
132
133// --- state
134
135const OFF: Radio = { status: 'off', note: null, volume: 60, isWindow: false, track: null, onSince: null, minute: 0 }
136const radio = atom({ plugin: 'lofi-radio', key: 'radio' } as const, OFF as Radio)
137const EMPTY_POND: Pond = { day: '', gold: [], lastAt: 0 }
138const pond = atom({ plugin: 'lofi-radio', key: 'pond' } as const, EMPTY_POND as Pond)
139
140// Merging over OFF fills fields a value saved by an older version lacks.
141const patch = ($: EngineInterface, change: Partial<Radio>) =>
142  update($, radio, (prev): Radio => ({ ...OFF, ...prev, ...change }))
143const current = async ($: EngineInterface): Promise<Radio> => ({ ...OFF, ...(await read($, radio)) })
144const isLive = (s: Radio) => s.status === 'on' || s.status === 'tuning'
145const clampVolume = (v: number) => Math.max(10, Math.min(100, v))
146
147const pad = (n: number) => String(n).padStart(2, '0')
148export const dayOf = (ms: number) => {
149  const d = new Date(ms)
150  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
151}
152const todaysFish = async ($: EngineInterface): Promise<Pond> => {
153  const p = { ...EMPTY_POND, ...(await read($, pond)) }
154  const day = dayOf(await $.clock.now())
155  return p.day === day ? p : { ...EMPTY_POND, day }
156}
157
158// Volume and window outlive the session; so does today's catch.
159async function savePrefs($: EngineInterface) {
160  const { volume, isWindow } = await current($)
161  await $.store.set('prefs', { volume, isWindow }).catch(() => undefined)
162}
163
164async function restoreSaved($: EngineInterface) {
165  const prefs = (await $.store.get('prefs').catch(() => undefined)) as Partial<Radio> | undefined
166  if (prefs && typeof prefs.volume === 'number') {
167    await patch($, { volume: clampVolume(prefs.volume), isWindow: prefs.isWindow === true })
168  }
169  const saved = (await $.store.get('pond').catch(() => undefined)) as Pond | undefined
170  if (saved && Array.isArray(saved.gold) && saved.day === dayOf(await $.clock.now())) {
171    await update($, pond, (): Pond => ({ ...EMPTY_POND, ...saved, lastAt: 0 }))
172  }
173}
174
175// --- the player
176
177const found = new Map<string, string>()
178let player: HookStream<ProcessSpawnChunk, ProcessSpawnResult> | null = null
179// Bumped on every play, restart and stop: a player loop whose number is old writes nothing.
180let generation = 0
181// One listen runs from play to stop and survives quiet restarts. Its timers and
182// now-playing reads carry its number and stop as soon as it changes.
183let listen = 0
184let minuteTimer: Timer | null = null
185let firstRead: Timer | null = null
186let readTimer: Timer | null = null
187
188async function bin($: EngineInterface, name: string, versionFlag: string) {
189  const known = found.get(name)
190  if (known) return known
191  for (const dir of BIN_DIRS) {
192    const path = dir + name
193    const ok = await $.process.run([path, versionFlag], { timeoutMs: 10_000 }).then(
194      r => r.exitCode === 0,
195      () => false,
196    )
197    if (ok) {
198      found.set(name, path)
199      return path
200    }
201  }
202  return null
203}
204
205// Last non-empty stderr line, short enough for the band.
206export function lastLine(text: string): string {
207  const lines = text.split('\n').map(l => l.trim()).filter(Boolean)
208  return (lines.at(-1) ?? '').slice(0, 80)
209}
210
211// Always a fresh address: one resolved a few minutes ago already answers 403.
212async function resolveUrl($: EngineInterface, ytdlp: string, format: string): Promise<{ url: string } | { error: string }> {
213  const r = await $.process
214    .run([ytdlp, '--no-update', '--no-warnings', '--no-playlist', '-f', format, '-g', STREAM_URL], { timeoutMs: 30_000 })
215    .catch(() => null)
216  const url = r?.exitCode === 0 ? (r.stdout.trim().split('\n')[0] ?? '') : ''
217  return url ? { url } : { error: lastLine(r?.stderr ?? '') || t().notFound }
218}
219
220async function killPlayers($: EngineInterface) {
221  await $.process.run(['pkill', '-f', MARK]).catch(() => undefined)
222}
223
224function stopTimers() {
225  minuteTimer?.cancel()
226  firstRead?.cancel()
227  readTimer?.cancel()
228  minuteTimer = firstRead = readTimer = null
229  failures = 0
230  isSlow = false
231}
232
233async function play($: EngineInterface) {
234  const id = ++generation
235  const l = ++listen
236  stopTimers()
237  rawTrack = null
238  await patch($, { status: 'tuning', note: null, track: null })
239
240  const ytdlp = await bin($, 'yt-dlp', '--version')
241  const ffplay = await bin($, 'ffplay', '-version')
242  if (id !== generation) return
243  if (!ytdlp || !ffplay) {
244    await patch($, { status: 'error', note: t().missing(!ytdlp ? 'yt-dlp' : 'ffmpeg') })
245    return
246  }
247
248  const stream = await resolveUrl($, ytdlp, FORMAT)
249  if (id !== generation) return
250  if ('error' in stream) {
251    await patch($, { status: 'error', note: stream.error })
252    return
253  }
254  await runPlayer($, id, l, ffplay, stream.url, false, 0)
255}
256
257// Volume and window are ffplay flags. A playing stream restarts with them quietly: the old
258// player keeps going while the new address resolves, then hands over within a second.
259async function restart($: EngineInterface) {
260  const s = await current($)
261  if (s.status === 'tuning') return void play($)
262  if (s.status !== 'on') return
263  const ytdlp = found.get('yt-dlp')
264  const ffplay = found.get('ffplay')
265  if (!ytdlp || !ffplay) return void play($)
266  const id = ++generation
267  const stream = await resolveUrl($, ytdlp, FORMAT)
268  if (id !== generation) return
269  const old = player
270  player = null
271  void old?.return({ code: null, signal: 'SIGTERM' })
272  if ('error' in stream) {
273    await killPlayers($)
274    return void play($)
275  }
276  await runPlayer($, id, listen, ffplay, stream.url, true, 0)
277}
278
279async function runPlayer($: EngineInterface, id: number, l: number, ffplay: string, url: string, isRestart: boolean, restarts: number) {
280  const s = await current($)
281  const view = s.isWindow ? ['-x', '480', '-y', '270', '-alwaysontop'] : ['-nodisp', '-vn']
282  // One stream at a time, also across sessions.
283  await killPlayers($)
284  if (id !== generation) return
285  const child = $.process.spawn({
286    argv: [ffplay, ...view, '-loglevel', 'warning', '-volume', String(s.volume), '-window_title', TITLE, url],
287  })
288  player = child
289  const spawnedAt = await $.clock.now()
290
291  if (!isRestart) {
292    const startedAt = spawnedAt
293    if (id !== generation) return
294    // Checked inside the updater too: a retried write must not put "on" back over a stop's "off".
295    await update($, radio, (prev): Radio =>
296      id === generation ? { ...OFF, ...prev, status: 'on', note: null, onSince: startedAt, minute: 0 } : { ...OFF, ...prev })
297    if (id !== generation) return
298    startTimers($, l)
299  }
300
301  let stderr = ''
302  let end: ProcessSpawnResult | null = null
303  let isStalled = false
304  let skips: number[] = []
305  try {
306    let step = await child.next()
307    while (!step.done) {
308      if (step.value.stream === 'stderr') {
309        stderr = (stderr + step.value.text).slice(-4_000)
310        const skipped = step.value.text.match(SKIP)?.length ?? 0
311        if (skipped) {
312          const now = await $.clock.now()
313          skips = [...skips, ...Array<number>(skipped).fill(now)].filter(at => now - at < STALL_WINDOW_MS)
314          if (skips.length >= STALL_SKIPS) {
315            isStalled = true
316            void child.return({ code: null, signal: 'SIGTERM' })
317            break
318          }
319        }
320      }
321      step = await child.next()
322    }
323    if (step.done) end = step.value
324  } catch (error) {
325    stderr += String(error)
326  }
327  if (id !== generation) return
328  player = null
329
330  if (isStalled) {
331    // ffplay would retry the dead address forever: make sure it is gone, then resolve afresh.
332    await killPlayers($)
333    const ytdlp = found.get('yt-dlp')
334    // A stream that played for a while before stalling gets a fresh budget.
335    const budget = (await $.clock.now()) - spawnedAt > HEALTHY_MS ? 0 : restarts
336    if (budget < MAX_RESTARTS && ytdlp) {
337      const stream = await resolveUrl($, ytdlp, FORMAT)
338      if (id !== generation) return
339      if (!('error' in stream)) return runPlayer($, id, l, ffplay, stream.url, true, budget + 1)
340    }
341  }
342
343  const wasKilled = end !== null && (end.signal !== null || end.code === FFPLAY_KILLED)
344  const failed = isStalled || (!wasKilled && (end === null || end.code !== 0))
345  // The listen is over: its timers and any read still running stop here.
346  listen++
347  stopTimers()
348  await patch($, failed
349    ? { status: 'error', note: isStalled ? t().stalled : lastLine(stderr) || t().ffplayStopped, onSince: null, minute: 0 }
350    : { status: 'off', note: null, track: null, onSince: null, minute: 0 })
351}
352
353async function stop($: EngineInterface) {
354  generation++
355  listen++
356  stopTimers()
357  rawTrack = null
358  const child = player
359  player = null
360  void child?.return({ code: null, signal: 'SIGTERM' })
361  await killPlayers($)
362  await patch($, { status: 'off', note: null, track: null, onSince: null, minute: 0 })
363}
364
365async function toggle($: EngineInterface) {
366  if (isLive(await current($))) return stop($)
367  void play($)
368}
369
370// Read and write in one update, so two quick presses both count.
371async function stepVolume($: EngineInterface, delta: number) {
372  let changed = false
373  await update($, radio, (prev): Radio => {
374    const s = { ...OFF, ...prev }
375    const volume = clampVolume(s.volume + delta)
376    changed = volume !== s.volume
377    return { ...s, volume }
378  })
379  if (!changed) return
380  await savePrefs($)
381  await restart($)
382}
383
384async function toggleWindow($: EngineInterface) {
385  const next = await update($, radio, (prev): Radio => {
386    const s = { ...OFF, ...prev }
387    return { ...s, isWindow: !s.isWindow }
388  })
389  await savePrefs($)
390  if (isLive({ ...OFF, ...next })) await restart($)
391  else void play($)
392}
393
394const openPlayer = ($: EngineInterface) => void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
395
396// --- now playing: the track name exists only burned into the video's top-right box.
397// A compiled macOS Vision helper reads a 28 s burst of that crop and stitches the marquee.
398
399const CROP = 'crop=iw*0.2219:ih*0.0611:iw*0.7438:ih*0.0347,scale=852:132:flags=lanczos,format=gray'
400// perl alarm + exec is a hard wall clock around ffmpeg, which otherwise retries a 403 forever.
401const FULL_SH = `/usr/bin/perl -e 'alarm 30; exec @ARGV' "$3" -nostdin -loglevel error -live_start_index -14 -i "$1" -an -t 28 -vf 'fps=2,${CROP}' -f rawvideo -pix_fmt gray - 2>/dev/null | "$2" --raw 852 132`
402const CHECK_SH = `/usr/bin/perl -e 'alarm 15; exec @ARGV' "$3" -nostdin -loglevel error -i "$1" -an -frames:v 1 -vf '${CROP}' -f rawvideo -pix_fmt gray - 2>/dev/null | "$2" --raw 852 132 --debug`
403// Compiles to a temporary name first, so a timed-out build never passes for a finished one.
404const COMPILE_SH = 'mkdir -p "$4" && "$1" -O "$2" -o "$3.$$.tmp" && mv "$3.$$.tmp" "$3"'
405
406let helper: Promise<string | null> | null = null
407// The marquee's own text ("Artist — 01 - Title"), which one-frame checks are matched against.
408let rawTrack: string | null = null
409// The listen whose read is running, so a stale read never blocks or is blamed on a new one.
410let readingListen: number | null = null
411let failures = 0
412let isSlow = false
413
414// A real compiler only: never the /usr/bin shim, which would open an install dialog.
415async function findSwiftc($: EngineInterface) {
416  if (await $.fs.exists(CLT_SWIFTC).catch(() => false)) return CLT_SWIFTC
417  const r = await $.process.run(['/usr/bin/xcode-select', '-p'], { timeoutMs: 5_000 }).catch(() => null)
418  const dev = r?.exitCode === 0 ? r.stdout.trim() : ''
419  if (!dev) return null
420  for (const path of [`${dev}/usr/bin/swiftc`, `${dev}/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc`]) {
421    if (await $.fs.exists(path).catch(() => false)) return path
422  }
423  return null
424}
425
426// Compiled once per machine into ~/.cache; without a compiler the feature stays off.
427function nowPlayingHelper($: EngineInterface) {
428  helper ??= (async () => {
429    const home = await $.env.get('HOME')
430    if (!home) return null
431    const dir = `${home}/.cache/lofi-radio`
432    const out = `${dir}/nowplaying-v${NP_VERSION}`
433    if (await $.fs.exists(out).catch(() => false)) return out
434    const swiftc = await findSwiftc($)
435    if (!swiftc) return null
436    const r = await $.process
437      .run(['/bin/sh', '-c', COMPILE_SH, 'sh', swiftc, `${$.plugin.root}/${NP_SOURCE}`, out, dir], { timeoutMs: 180_000 })
438      .catch(() => null)
439    return r?.exitCode === 0 ? out : null
440  })()
441  return helper
442}
443
444const norm = (s: string) => s.toLowerCase().replace(/[—–]/g, '-').replace(/\s+/g, ' ').trim()
445
446// "Kyle Preston — 01 - We Made It" shows as "Kyle Preston — We Made It".
447export function cleanTrack(artist: string, title: string): string {
448  const t = title.replace(/^\d{1,2}\s*-\s*/, '').trim()
449  return artist ? `${artist.trim()} — ${t}` : t
450}
451
452// True when a one-frame fragment still fits inside the known track's looping marquee.
453export function stillPlaying(track: string, fragment: string): boolean {
454  const f = norm(fragment)
455  const inner = f.length > 8 ? f.slice(1, -1) : f
456  return inner.length >= 4 && norm(`${track} ${track}`).includes(inner)
457}
458
459type HelperOut = { track?: string; artist?: string; title?: string; complete?: boolean; fragments?: string[] }
460function parseHelper(stdout: string | undefined): HelperOut | null {
461  try {
462    return stdout ? (JSON.parse(stdout.trim().split('\n').at(-1) ?? '') as HelperOut) : null
463  } catch {
464    return null
465  }
466}
467
468async function readTrack($: EngineInterface, l: number, full: boolean) {
469  if (l !== listen || readingListen === l) return
470  readingListen = l
471  try {
472    const [helperPath, ytdlp, ffmpeg] = await Promise.all([
473      nowPlayingHelper($),
474      bin($, 'yt-dlp', '--version'),
475      bin($, 'ffmpeg', '-version'),
476    ])
477    // Missing tools turn the feature off; that is not a miss.
478    if (l !== listen || !helperPath || !ytdlp || !ffmpeg) return
479    const ocr = await resolveUrl($, ytdlp, OCR_FORMAT)
480    if (l !== listen) return
481    if ('error' in ocr) return fail($, l)
482
483    const known = (await current($)).track
484    if (!full && known && rawTrack) {
485      const r = await $.process.run(['/bin/sh', '-c', CHECK_SH, 'sh', ocr.url, helperPath, ffmpeg], { timeoutMs: 25_000 }).catch(() => null)
486      if (l !== listen) return
487      const fragment = parseHelper(r?.stdout)?.fragments?.[0] ?? ''
488      if (!fragment) return fail($, l)
489      if (stillPlaying(rawTrack, fragment)) return recovered($, l)
490    }
491
492    const r = await $.process.run(['/bin/sh', '-c', FULL_SH, 'sh', ocr.url, helperPath, ffmpeg], { timeoutMs: 45_000 }).catch(() => null)
493    if (l !== listen) return
494    const out = parseHelper(r?.stdout)
495    if (!out?.complete) return fail($, l)
496    recovered($, l)
497    rawTrack = out.track ?? null
498    const track = cleanTrack(out.artist ?? '', out.title ?? '')
499    await update($, radio, (prev): Radio => (l === listen ? { ...OFF, ...prev, track } : { ...OFF, ...prev }))
500  } finally {
501    if (readingListen === l) readingListen = null
502  }
503}
504
505function setCadence($: EngineInterface, l: number, ms: number) {
506  readTimer?.cancel()
507  readTimer = $.clock.every(ms, () => void readTrack($, l, false))
508}
509
510// Three misses in a row slow the checks to every five minutes; the last known track stays shown.
511function fail($: EngineInterface, l: number) {
512  if (l !== listen || !readTimer) return
513  failures++
514  if (failures >= 3 && !isSlow) {
515    isSlow = true
516    setCadence($, l, BACKOFF_MS)
517  }
518}
519
520function recovered($: EngineInterface, l: number) {
521  failures = 0
522  if (isSlow && l === listen && readTimer) {
523    isSlow = false
524    setCadence($, l, CHECK_MS)
525  }
526}
527
528function startTimers($: EngineInterface, l: number) {
529  stopTimers()
530  minuteTimer = $.clock.every(60_000, () => {
531    void (async () => {
532      if (l !== listen) return
533      const s = await current($)
534      if (s.status === 'on' && s.onSince !== null) {
535        const minute = Math.floor(((await $.clock.now()) - s.onSince) / 60_000)
536        if (l === listen) await patch($, { minute })
537      }
538    })()
539  })
540  firstRead = $.clock.after(5_000, () => void readTrack($, l, true))
541  setCadence($, l, CHECK_MS)
542}
543
544// --- the player scene: a small window onto the stream. Dot-matrix sky, ranges and
545// treeline, drifting water and Clawd fishing from a boat, drawn as one SVG. At most
546// three slow loops move (water drift, boat bob, status dot) and only while playing.
547
548const CLAY = '#D97757'
549const INK = '#1E1E1E'
550const MONO = "ui-monospace,'SF Mono',Menlo,monospace"
551
552type Px = [number, number, number, number, string]
553const px = (list: Px[]) => list.map(([x, y, w, h, c]) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${c}"/>`).join('')
554
555// Pixel Clawd after johnnyvizz's savvy-progress (MIT, see LICENSE) by way of agent-deck,
556// posed for the stream: rod in the left claw, the hull hiding his legs. The character is Anthropic's.
557const BODY: Px[] = [[7, 10, 16, 12, CLAY], [3, 14, 4, 4, CLAY], [23, 14, 4, 4, CLAY]]
558const EYES: Record<Radio['status'], Px[]> = {
559  on: [[9, 12, 2, 2, INK], [19, 12, 2, 2, INK]],
560  tuning: [[9, 12, 2, 2, INK], [19, 12, 2, 2, INK]],
561  off: [[9, 13, 2, 1, INK], [19, 13, 2, 1, INK]],
562  error: [
563    [8, 11, 1, 1, INK], [10, 11, 1, 1, INK], [9, 12, 1, 1, INK], [8, 13, 1, 1, INK], [10, 13, 1, 1, INK],
564    [18, 11, 1, 1, INK], [20, 11, 1, 1, INK], [19, 12, 1, 1, INK], [18, 13, 1, 1, INK], [20, 13, 1, 1, INK],
565  ],
566}
567const SLEEP_Z: Px[] = [[24, 4, 3, 1, '#8A8A8A'], [25, 5, 1, 1, '#8A8A8A'], [24, 6, 3, 1, '#8A8A8A']]
568const ROD: Px[] = [[4, 4, 1, 10, '#9390B9'], [3, 3, 2, 1, '#E8E8E8']]
569const HULL: Px[] = [
570  [-3, 18, 3, 1, '#B07A45'], [30, 18, 3, 1, '#B07A45'],
571  [-2, 19, 34, 1, '#B07A45'], [-1, 20, 32, 1, '#8B6123'], [0, 21, 30, 1, '#8B6123'], [1, 22, 28, 1, '#6D4119'], [3, 23, 24, 1, '#6D4119'],
572]
573// 5×3, facing left; gold for a turn of ten minutes or more.
574const fishPx = (x: number, y: number, gold: boolean): Px[] => {
575  const body = gold ? '#F0A27F' : '#6699D4'
576  const tail = gold ? '#D9825F' : '#4D7FBF'
577  return [[x + 1, y, 2, 1, body], [x, y + 1, 4, 1, body], [x + 1, y + 2, 2, 1, body], [x + 4, y, 1, 3, tail], [x + 1, y + 1, 1, 1, INK]]
578}
579const FISH_SPOTS = [[28, 16], [33, 16], [30.5, 13], [35.5, 13]] as const
580// A 9×9 pixel ♫.
581const NOTE: Px[] = [[2, 0, 7, 2, CLAY], [2, 2, 1, 5, CLAY], [8, 2, 1, 4, CLAY], [0, 6, 3, 3, CLAY], [6, 5, 3, 3, CLAY]]
582
583const ridge = (x: number, W: number, base: number, amp: number, seed: number) => {
584  const t = (x / W) * Math.PI * 2
585  return base - amp * (0.5 * Math.sin(t * 1.1 + seed) + 0.3 * Math.sin(t * 2.7 + seed * 1.9) + 0.2 * Math.sin(t * 6.3 + seed * 3.1))
586}
587// Deterministic 0..1 noise, so the same size always draws the same scene.
588const noise = (i: number, seed: number) => {
589  const s = Math.sin(i * 127.1 + seed * 311.7) * 43758.5453
590  return s - Math.floor(s)
591}
592const f1 = (n: number) => Math.round(n * 10) / 10
593
594export const fit = (s: string, max: number) => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
595const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
596
597export const elapsed = (minute: number, ts: Strings = t()) => ts.minutes(minute)
598const statusLabel = (status: Radio['status'], ts: Strings) => ({ on: ts.live, tuning: ts.tuning, off: ts.off, error: ts.error })[status]
599const ROOT_CLASS: Record<Radio['status'], string> = { on: 'on', tuning: 'tune', off: 'off', error: 'err' }
600
601export function altText(s: Radio, fishCount: number, ts: Strings = t()): string {
602  const parts = [`Claude FM: ${statusLabel(s.status, ts)}`]
603  if (s.status === 'on' && s.track) parts.push(s.track)
604  if (s.status === 'on') parts.push(elapsed(s.minute, ts))
605  parts.push(ts.volume(s.volume))
606  if (fishCount > 0) parts.push(ts.fishToday(fishCount))
607  if (s.status === 'error' && s.note) parts.push(s.note)
608  return parts.join(' · ')
609}
610
611export function scene(W: number, H: number, s: Radio, gold: boolean[], now: number, popFish = false, ts: Strings = t()): string {
612  const k = W / 480
613  const p = Math.max(3, Math.round(3.3 * k))
614  const S = Math.max(2, Math.round(H / 95))
615  const wy = Math.round(H * 0.74)
616  const phase = (period: number) => `animation-delay:-${((now % period) / 1000).toFixed(2)}s`
617
618  const css = `<style>
619.on .w{animation:drift 24s linear infinite}.on .wr{animation:drift 24s linear infinite reverse}
620@keyframes drift{to{stroke-dashoffset:-24}}
621.on .bob{animation:bob 6s infinite}@keyframes bob{0%,49.9%{transform:translateY(0)}50%,100%{transform:translateY(1px)}}
622.on .dot{animation:breathe 4s ease-in-out infinite}.tune .dot{animation:breathe 1.2s ease-in-out infinite}
623@keyframes breathe{50%{opacity:.5}}
624.tune .seg{animation:slide 1.6s ease-in-out infinite alternate}@keyframes slide{to{transform:translateX(${f1((W - 24) * 0.7)}px)}}
625.pop{transform-box:fill-box;transform-origin:center;animation:pop .6s ease-out both}@keyframes pop{from{transform:scale(.6)}}
626@media (prefers-color-scheme: light){.card{stroke:rgba(0,0,0,.10)}}
627@media (prefers-reduced-motion: reduce){*{animation:none!important}}
628</style>`
629
630  const defs = `<defs>
631<pattern id="p0" width="${2 * p}" height="${2 * p}" patternUnits="userSpaceOnUse"><rect x="${p}" y="${p}" width="1" height="1" fill="#5C5C5C"/></pattern>
632<pattern id="p1" width="${p}" height="${p}" patternUnits="userSpaceOnUse"><rect x="1" y="1" width="1" height="1" fill="#8B8B8B"/></pattern>
633<pattern id="p2" width="${2 * p}" height="${2 * p}" patternUnits="userSpaceOnUse"><rect y="1" width="${2 * p}" height="${f1(0.4 * p)}" fill="#B8B8B8"/><rect x="1" y="${p + 1}" width="1" height="1" fill="#8B8B8B"/><rect x="${p + 1}" y="${p + 1}" width="1" height="1" fill="#8B8B8B"/></pattern>
634<pattern id="p3" width="${2 * p}" height="${2 * p}" patternUnits="userSpaceOnUse"><rect y="1" width="${f1(1.6 * p)}" height="${f1(0.45 * p)}" fill="#D0D0D0"/></pattern>
635<linearGradient id="fade" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff"/><stop offset=".6" stop-color="#000"/></linearGradient>
636<mask id="mfade" maskContentUnits="objectBoundingBox"><rect width="1" height="1" fill="url(#fade)"/></mask>
637<linearGradient id="scrim" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".55"/></linearGradient>
638<clipPath id="card"><rect width="${W}" height="${H}" rx="10"/></clipPath>
639</defs>`
640
641  // Each shape is laid opaque first, so nearer layers hide farther ones.
642  const layer = (d: string, fill: string, bright?: string) =>
643    `<path d="${d}" fill="${INK}"/><path d="${d}" fill="url(#${fill})"/>${bright ? `<path d="${d}" fill="url(#${bright})" mask="url(#mfade)"/>` : ''}`
644
645  const stars: string[] = []
646  for (let i = 0; i < 18; i++) {
647    const x = Math.round(noise(i, 1) * W)
648    const y = Math.round(noise(i, 2) * H * 0.28)
649    stars.push(`<rect x="${x}" y="${y}" width="1" height="1" fill="#8B8B8B"/>`)
650  }
651
652  // The stream's big bright cloud bank hangs from the top left.
653  const cw = W * 0.55
654  const ch = H * 0.3
655  let cloud = `M0 0H${f1(cw)}`
656  for (let x = cw; x >= 0; x -= 6) {
657    const env = 1 - (x / cw) ** 2
658    cloud += `L${f1(x)} ${f1(ch * env * (0.8 + 0.2 * Math.sin(x * 0.025 + 1.3)))}`
659  }
660  cloud += 'Z'
661
662  let far = `M0 ${wy}`
663  for (let x = 0; x <= W; x += 4) far += `L${x} ${f1(ridge(x, W, H * 0.45, H * 0.2, 1.3))}`
664  far += `L${W} ${wy}Z`
665
666  // Pines: seeded spikes along 0.66H.
667  const base = H * 0.66
668  let trees = `M0 ${wy}L0 ${f1(base)}`
669  for (let x = 0, i = 0; x < W; i++) {
670    const step = (5 + noise(i, 3) * 7) * k
671    const h = (6 + noise(i, 4) * 16) * k
672    trees += `L${f1(x + step / 2)} ${f1(base - h)}L${f1(x + step)} ${f1(base)}`
673    x += step
674  }
675  trees += `L${W} ${wy}Z`
676
677  const water: string[] = []
678  const rows = 12
679  const gap = (H - 30 - (wy + 4)) / (rows - 1)
680  for (let i = 0; i < rows; i++) {
681    const y = f1(wy + 4 + i * gap)
682    const opacity = (0.14 + (0.24 * i) / (rows - 1)).toFixed(2)
683    water.push(
684      `<path class="${i % 2 ? 'wr' : 'w'}" style="${phase(24_000)}" d="M0 ${y}H${W}" stroke="#D6D6D6" stroke-width="1.2" stroke-dasharray="${1 + (i % 3)} ${4 + ((i * 7) % 9)}" stroke-dashoffset="${(i * 13) % 17}" opacity="${opacity}" fill="none"/>`,
685    )
686  }
687
688  // Clawd's grid scaled by S: hull centred on x 0.5W, its bottom row on 0.85H.
689  const bx = Math.round(W * 0.5 - 15 * S)
690  const by = Math.round(H * 0.85 - 24 * S)
691  const shown = gold.slice(-4)
692  const fish = shown.flatMap((g, i) => fishPx(FISH_SPOTS[i]![0], FISH_SPOTS[i]![1], g))
693  const lastFish = shown.length ? fishPx(FISH_SPOTS[shown.length - 1]![0], FISH_SPOTS[shown.length - 1]![1], shown.at(-1)!) : []
694  const fishSvg = popFish && shown.length
695    ? px(fish.slice(0, -lastFish.length)) + `<g class="pop">${px(lastFish)}</g>`
696    : px(fish)
697  const more = gold.length > 4
698    ? `<text x="${bx + 41.5 * S}" y="${by + 15.5 * S}" font-family="${MONO}" font-size="10" fill="#8B8B8B">+${gold.length - 4}</text>`
699    : ''
700  const line = `<path d="M3.5 3.5L-1.5 3.5V${f1((wy - by) / S + 1)}" stroke="#E8E8E8" stroke-width=".3" opacity=".6" fill="none"/>`
701  const boat = `<g class="bob" style="${phase(6_000)}"><g transform="translate(${bx},${by}) scale(${S})" shape-rendering="crispEdges">
702${line}${px(ROD)}${px(BODY)}${px(EYES[s.status])}${s.status === 'off' ? px(SLEEP_Z) : ''}${px(HULL)}${fishSvg}
703</g>${more}</g>`
704
705  // Now-playing box, top right.
706  const label =
707    s.status === 'on' && s.track ? { text: s.track, color: '#D6D6D6' }
708    : s.status === 'on' ? { text: `Claude FM — ${ts.tagline}`, color: '#8F8F8F' }
709    : s.status === 'tuning' ? { text: ts.boxTuning, color: '#E0B44C' }
710    : s.status === 'error' ? { text: s.note ?? ts.boxError, color: '#FF8A80' }
711    : { text: ts.boxOff, color: '#7A7A7A' }
712  const maxChars = Math.floor((W * 0.62 - 38) / 7.2)
713  const text = fit(label.text, maxChars)
714  const bw = Math.round(Math.min(W * 0.62, text.length * 7.2 + 38))
715  const boxX = W - 12 - bw
716  const boxStroke = s.status === 'error' ? 'stroke="#FF4E45" stroke-opacity=".6"' : 'stroke="#FFFFFF" stroke-opacity=".08"'
717  const box = `<rect x="${boxX}" y="12" width="${bw}" height="26" rx="3" fill="#131313" fill-opacity=".92" ${boxStroke}/>
718<g transform="translate(${boxX + 10},20.5)" shape-rendering="crispEdges">${px(NOTE)}</g>
719<text x="${boxX + 26}" y="29.5" font-family="${MONO}" font-size="12" fill="${label.color}">${esc(text)}</text>`
720
721  // YouTube-style live bar.
722  const barW = W - 24
723  const bar =
724    s.status === 'on' ? `<rect x="12" y="${H - 24}" width="${barW}" height="3" rx="1.5" fill="#FF0033"/><circle cx="${W - 12}" cy="${H - 22.5}" r="5" fill="#FF0033"/>`
725    : s.status === 'tuning' ? `<rect x="12" y="${H - 24}" width="${barW}" height="3" rx="1.5" fill="#FFFFFF" fill-opacity=".15"/><rect class="seg" x="12" y="${H - 24}" width="${f1(barW * 0.3)}" height="3" rx="1.5" fill="#E0B44C"/>`
726    : s.status === 'error' ? `<line x1="12" y1="${H - 22.5}" x2="${W - 12}" y2="${H - 22.5}" stroke="#7A2A26" stroke-width="3" stroke-dasharray="6 4"/>`
727    : `<rect x="12" y="${H - 24}" width="${barW}" height="3" rx="1.5" fill="#FFFFFF" fill-opacity=".12"/>`
728
729  // Info row: status and listening time left, catch and volume right.
730  const dotColor = s.status === 'on' ? '#FF4E45' : s.status === 'tuning' ? '#E0B44C' : s.status === 'error' ? '#FF4E45' : '#8A8A8A'
731  const statusText = s.status === 'on' ? `${ts.live} · ${elapsed(s.minute, ts)}` : statusLabel(s.status, ts)
732  const meterRight = W - 12 - 20
733  const meter: string[] = []
734  for (let i = 0; i < 10; i++) {
735    const h = 3 + (i * 5) / 9
736    const x = meterRight - 38 + i * 4
737    meter.push(`<rect x="${x}" y="${f1(H - 8 - h)}" width="2" height="${f1(h)}" fill="#FFFFFF" fill-opacity="${i < s.volume / 10 ? 1 : 0.18}"/>`)
738  }
739  const catchText = gold.length > 0 && s.status !== 'off' && W >= 360
740    ? `<text x="${meterRight - 46}" y="${H - 8}" text-anchor="end" font-family="${MONO}" font-size="10" fill="#8B8B8B">&gt;&lt;&gt; ×${gold.length}</text>`
741    : ''
742  const info = `<circle class="dot" style="${phase(s.status === 'tuning' ? 1_200 : 4_000)}" cx="18" cy="${H - 12}" r="3.5" fill="${dotColor}"/>
743<text x="28" y="${H - 8}" font-family="${MONO}" font-size="11" fill="#E8E8E8">${statusText}</text>
744${catchText}${meter.join('')}
745<text x="${W - 12}" y="${H - 8}" text-anchor="end" font-family="${MONO}" font-size="10" fill="#8B8B8B">${s.volume}</text>`
746
747  const sceneOpacity = { on: 1, tuning: 0.6, off: 0.55, error: 0.45 }[s.status]
748
749  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${css}${defs}
750<rect class="card" width="${W}" height="${H}" rx="10" fill="${INK}" stroke="none"/>
751<g class="${ROOT_CLASS[s.status]}" clip-path="url(#card)">
752<g opacity="${sceneOpacity}">
753${stars.join('')}
754${layer(cloud, 'p1', 'p3')}
755${layer(far, 'p1', 'p3')}
756<g opacity=".75">${layer(trees, 'p2')}</g>
757${water.join('')}
758${boat}
759</g>
760<rect y="${H - 44}" width="${W}" height="44" fill="url(#scrim)"/>
761${box}
762${bar}
763${info}
764</g>
765</svg>`
766}
767
768export const register: Register = (on, options) => {
769  on('session.start', async ($, e, n) => {
770    lang = await detectLang($, options.language)
771    await $.command.register({ name: 'lofi', description: t().command })
772    // A reload killed the old module's player; the shared state must not claim it still plays.
773    if (!player) await patch($, { status: 'off', note: null, track: null, onSince: null, minute: 0 })
774    await restoreSaved($)
775
776    return n(e)
777  })
778
779  // One fish per finished main-loop turn while the radio plays. The store is the
780  // count of record, so sessions on the same day add to one catch.
781  on('turn.complete', async ($, e, n) => {
782    if (!e.agentId && e.reason === 'answer' && (await current($)).status === 'on') {
783      const now = await $.clock.now()
784      const day = dayOf(now)
785      const saved = (await $.store.get('pond').catch(() => undefined)) as Pond | undefined
786      const base = saved && saved.day === day && Array.isArray(saved.gold) ? saved.gold : []
787      const next: Pond = { day, gold: [...base, e.durationMs >= 600_000].slice(-500), lastAt: now }
788      await $.store.set('pond', next).catch(() => undefined)
789      await update($, pond, (): Pond => next)
790    }
791
792    return n(e)
793  })
794
795  on('command.run', { command: 'lofi' }, async ($, e) => {
796    if (e.args.trim() === 'stop') {
797      await stop($)
798      return { text: t().stopped }
799    }
800    openPlayer($)
801    if (isLive(await current($))) return { text: t().playing }
802    void play($)
803    return { text: t().starting }
804  })
805
806  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
807    const s = await current($)
808    const fish = await todaysFish($)
809    const now = await $.clock.now()
810    const ts = t()
811    const ui = $.ui.resolve(e)
812    const { Box, Button, Link, Text } = ui
813    const W = Math.max(300, Math.min(720, (e.props.bodyColumns || 60) * 8 - 8))
814
815    const main = { on: ts.stop, tuning: ts.cancel, off: ts.play, error: ts.retry }[s.status]
816    const controls = (
817      <Box key="fm-controls" flexDirection="row" alignItems="center" gap={1} flexWrap="wrap">
818        <Button key="fm-main" variant="primary" hotkey="p" label={main} onPress={() => void toggle($)} />
819        <Button key="fm-down" hotkey="j" label="−" dimColor={s.volume <= 10} onPress={() => void stepVolume($, -10)} />
820        <Button key="fm-up" hotkey="k" label="+" dimColor={s.volume >= 100} onPress={() => void stepVolume($, 10)} />
821        <Button
822          key="fm-window"
823          hotkey="m"
824          label={W < 420 ? '▣' : s.isWindow ? ts.closeMini : ts.mini}
825          onPress={() => void toggleWindow($)}
826        />
827        <Link key="fm-yt" href={STREAM_URL} label="YouTube ↗" />
828      </Box>
829    )
830    const note = s.status === 'error' && s.note ? <Text key="fm-note" color="error">{s.note}</Text> : null
831    const foot = <Text key="fm-foot" dimColor>{ts.unofficial}</Text>
832
833    // The terminal's table may still name Svg; only the remote surfaces draw it.
834    if (e.surface !== 'terminal' && 'Svg' in ui) {
835      const { Svg } = ui
836      const H = Math.round(Math.max(190, Math.min(320, W * 0.5625)))
837      const isNewFish = now - fish.lastAt < 2_000
838      return (
839        <Box flexDirection="column" gap={1}>
840          <Svg key="fm-scene" source={scene(W, H, s, fish.gold, now, isNewFish, ts)} alt={altText(s, fish.gold.length, ts)} width={W} height={H} />
841          {controls}
842          {note}
843          {foot}
844        </Box>
845      )
846    }
847
848    const meter = '▮'.repeat(s.volume / 10) + '▯'.repeat(10 - s.volume / 10)
849    const status = [s.status === 'on' ? `${ts.live} · ${elapsed(s.minute, ts)}` : statusLabel(s.status, ts), `${ts.volumeShort} ${meter}`]
850    if (fish.gold.length) status.push(`><> ×${fish.gold.length}`)
851    return (
852      <Box flexDirection="column">
853        <Text color="claude" bold>
854          ♫ Claude FM
855        </Text>
856        {s.status === 'on' && s.track ? <Text>♪ {s.track}</Text> : <Text dimColor>{ts.tagline}</Text>}
857        <Text dimColor>{'    .:.        .::.            .:.'}</Text>
858        <Text dimColor>{'  .:::::.  .:::::::::.   .:. .:::::.'}</Text>
859        <Text dimColor>{' ~ ~ ~ ~ ~ ~ ~ \\__/ ~ ~ ~ ~ ~ ~ ~ ~'}</Text>
860        <Text color={s.status === 'on' ? 'claude' : undefined} dimColor={s.status !== 'on'}>
861          ● {status.join(' · ')}
862        </Text>
863        {controls}
864        {note}
865        {foot}
866      </Box>
867    )
868  })
869
870  on('ui.render', { component: 'AbovePrompt' }, async ($, e, n) => {
871    if (e.props.hasSurvey) return n(e)
872
873    const s = await current($)
874    const ts = t()
875    const rest = await n(e)
876    const { Box, Button, Text } = $.ui.resolve(e)
877
878    const row =
879      s.status === 'on' ? (
880        <Box key="fm" gap={1}>
881          <Text color="claude">♪ {fit(s.track ?? 'Claude FM', Math.max(12, (e.props.bodyColumns || 80) - 28))}</Text>
882          <Text dimColor>· {elapsed(s.minute, ts)}</Text>
883          <Button key="fm-open" label={ts.player} plain dimColor onPress={() => openPlayer($)} />
884          <Button key="fm-stop" label="■" plain dimColor onPress={() => void stop($)} />
885        </Box>
886      ) : s.status === 'tuning' ? (
887        <Box key="fm" gap={1}>
888          <Text dimColor>{ts.bandTuning}</Text>
889          <Button key="fm-stop" label={ts.bandCancel} dimColor onPress={() => void stop($)} />
890        </Box>
891      ) : s.status === 'error' ? (
892        <Box key="fm" gap={1}>
893          <Text dimColor>♪ Claude FM: {s.note}</Text>
894          <Button key="fm-play" label={ts.bandRetry} dimColor onPress={() => void play($)} />
895        </Box>
896      ) : (
897        <Box key="fm">
898          <Button
899            key="fm-play"
900            label={ts.bandPlay}
901            dimColor
902            plain
903            onPress={() => {
904              void play($)
905              openPlayer($)
906            }}
907          />
908        </Box>
909      )
910
911    return (
912      <Box flexDirection="column">
913        {row}
914        {rest}
915      </Box>
916    )
917  })
918}
919
types/index.d.ts 24 lines
1export type Radio = {
2  status: 'off' | 'tuning' | 'on' | 'error'
3  note: string | null
4  // ffplay's -volume, 10 to 100.
5  volume: number
6  // Video in a small always-on-top ffplay window instead of audio only.
7  isWindow: boolean
8  // "Artist — Title" read from the stream's now-playing box; null until read.
9  track: string | null
10  // When the current listen started (ms); null while not playing.
11  onSince: number | null
12  // Whole minutes since onSince, ticked once a minute so the pane redraws rarely.
13  minute: number
14}
15
16// One fish per finished Claude turn while the radio plays, counted per local day.
17export type Pond = { day: string; gold: boolean[]; lastAt: number }
18
19declare module 'claude-code' {
20  interface PluginState {
21    'lofi-radio': { radio: Radio; pond: Pond }
22  }
23}
24