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

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

A Claude Code mod (a plugin of function hooks) that adds:
/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.
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)brew upgrade yt-dlp. YouTube changes often break older versions.xcode-select --install). Without them the radio still plays and the pane shows the stream's tagline instead.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.
/lofi.p), − / + for volume (j / k), ▣ Mini window (m) for the video, and a link to the stream on YouTube./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.
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.
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.
MIT for the code. See LICENSE and NOTICE.
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.
hooks/register.tsx 919 lines1import { 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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">><> ×${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}
919types/index.d.ts 24 lines1export 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