Lo-fi, jazz, classical and ambient radio inside Claude Code, with a little pixel mascot who swaps the records.

A tiny radio player that lives inside Claude Code. A little pixel mascot spins the record, and swaps it when you change the genre.
██▀██████▀██
▀▀████████████▀▀ ◐ ○ lofi ● jazz ○ classical ○ ambient
▀ ▀ ▀ ▀
A three-row band sits above your prompt: Clawd stands at a turntable, the record turns while a station plays, and when the genre changes Clawd lifts the old record, reaches into the crate and sets the new one down. The sound starts only after the record is down. Open the pane for genres, stations, transport, volume, a mood box and a focus timer.
Unofficial. Not affiliated with or endorsed by Anthropic. See NOTICE.
Requires Claude Code 2.1.275 or later and ffplay (winget install Gyan.FFmpeg). yt-dlp is optional and enables the YouTube stations.
/plugin install clawd-music --marketplace Sansay411/Clawd_Music_Claude_Code_Plugin
Or try it for one session without installing:
git clone https://github.com/Sansay411/Clawd_Music_Claude_Code_Plugin
claude --plugin-dir ./Clawd_Music_Claude_Code_Plugin
Then type /music.
| Command | What it does | ||
|---|---|---|---|
/music | open the pane | ||
/music play [genre or station] | a genre (random station) or a station by name; no argument resumes | ||
/music stop / pause | stop (keeps the station) / pause or resume | ||
/music next / prev | next station in the genre / the previous one | ||
| `/music vol <0-100\ | up\ | down>` | volume |
/music mute | mute or unmute | ||
/music mood <text> | pick a genre from mood words (English and Russian): /music mood rainy focus | ||
/music vibe / shuffle | a random genre | ||
/music focus [minutes] | a timer, 1-120 min; the music stops and a chime plays at the end | ||
/music list / status / reset / help | list genres, one-line status, reset, help |
Short aliases also work: /play /stop /pause /next /prev /volume /mute /mood /vibe /shuffle /focus /list.
Keys in the pane and the band: b prev, p play/pause, n next, m mute, d / u volume, 1-8 genres. Click the band, or press ctrl+x tab, to focus it.
where.exe and taskkill. macOS/Linux support is the best first contribution, see CONTRIBUTING.ffplay.exe on the machine, so another program's ffplay stops too.yt-dlp and YouTube's own terms; use them at your own discretion.claude plugin validate .
claude plugin test .
Layout: hooks/register.tsx registers everything; src/reducer.ts is the pure player state machine; src/controller.ts owns ffplay; src/clawd*.ts(x) draw and animate Clawd; src/pane.tsx and src/band.tsx are the two views.
Мини-радио прямо в Claude Code: пиксельный Клодик крутит пластинку и меняет её при смене жанра. Без скиллов, агентов и телеметрии. Работает в терминале, десктопе и VS Code (пока только Windows). Нужен ffplay: winget install Gyan.FFmpeg. Установка и команды выше, запуск: /music.
Apache-2.0. Station streams belong to their operators.
hooks/register.tsx 125 lines1// The hooks module of clawd-music: session start and end, the band above the
2// prompt, the Claude Music pane, and the slash commands. Everything the
3// person sees goes through panes, bands, status, toasts and commands; no
4// prompt text is injected and no skill or agent is shipped.
5//
6// The engine's $ is used only in this file. makeIo() turns it into the narrow
7// Io door the other modules take, so no module outside this file receives $.
8// The state atoms are declared here too, with literal references, because the
9// engine's state library reads only references it can see in this file.
10
11import type { EngineInterface, Register } from 'claude-code'
12import { atom, read, update } from 'claude-code'
13import { bandTree } from '../src/band'
14import { COMMAND_SPECS, registerCommands, runCommand } from '../src/commands'
15import { endSession, ensureTicker, noteStaleSession, startSession } from '../src/controller'
16import type { Io } from '../src/io'
17import { paneTree } from '../src/pane'
18import { INITIAL_ANIM, INITIAL_CATALOG, INITIAL_PANE, INITIAL_PLAYER, INITIAL_SESSION, INITIAL_TOOLS } from '../src/state'
19
20const playerAtom = atom({ plugin: 'clawd-music', key: 'player' } as const, INITIAL_PLAYER)
21const animAtom = atom({ plugin: 'clawd-music', key: 'anim' } as const, INITIAL_ANIM)
22const catalogAtom = atom({ plugin: 'clawd-music', key: 'catalog' } as const, INITIAL_CATALOG)
23const toolsAtom = atom({ plugin: 'clawd-music', key: 'tools' } as const, INITIAL_TOOLS)
24const paneAtom = atom({ plugin: 'clawd-music', key: 'pane' } as const, INITIAL_PANE)
25const sessionAtom = atom({ plugin: 'clawd-music', key: 'session' } as const, INITIAL_SESSION)
26
27function makeIo($: EngineInterface): Io {
28 return {
29 pluginRoot: $.plugin.root,
30 readText: path => $.fs.read(path),
31 now: () => $.clock.now(),
32 player: {
33 read: () => read($, playerAtom),
34 update: change => update($, playerAtom, change),
35 },
36 anim: {
37 read: () => read($, animAtom),
38 update: change => update($, animAtom, change),
39 },
40 catalog: {
41 read: () => read($, catalogAtom),
42 update: change => update($, catalogAtom, change),
43 },
44 tools: {
45 read: () => read($, toolsAtom),
46 update: change => update($, toolsAtom, change),
47 },
48 pane: {
49 read: () => read($, paneAtom),
50 update: change => update($, paneAtom, change),
51 },
52 session: {
53 read: () => read($, sessionAtom),
54 update: change => update($, sessionAtom, change),
55 },
56 exists: path => $.fs.exists(path),
57 envPath: () => $.env.get('PATH'),
58 envSystemRoot: () => $.env.get('SystemRoot'),
59 run: (argv, timeoutMs) => $.process.run([...argv], { timeoutMs }),
60 spawn: argv => $.process.spawn({ argv: [...argv] }),
61 getStore: key => $.store.get(key),
62 setStore: (key, value) => $.store.set(key, value),
63 toast: (text, timeoutMs) => $.ui.toast(text, { timeoutMs }),
64 status: text => $.ui.status(text),
65 debug: text => $.ui.log(text, { to: 'debug' }),
66 after: (ms, fn) => $.clock.after(ms, fn),
67 every: (ms, fn) => $.clock.every(ms, fn),
68 open: async args => {
69 const opened = await $.ui.open({
70 id: args.id,
71 title: args.title,
72 focus: true,
73 closeOnEscape: true,
74 rows: args.rows,
75 columns: args.columns,
76 })
77 return opened.isPlaced ? { isPlaced: true, reason: '' } : { isPlaced: false, reason: opened.reason }
78 },
79 registerCommand: async spec => {
80 await $.command.register(spec)
81 },
82 }
83}
84
85export const register: Register = (on, _options) => {
86 on('session.start', async ($, e, next) => {
87 const io = makeIo($)
88 await registerCommands(io)
89 await startSession(io, e)
90 return next(e)
91 })
92
93 on('session.end', async ($, e, next) => {
94 await endSession(makeIo($), e.reason)
95 return next(e)
96 })
97
98 // The render hooks start the ticker when it is not running: after a hot
99 // reload no session.start follows, and drawing is what runs again first.
100 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
101 if (e.props.hasSurvey) return next(e)
102 const io = makeIo($)
103 ensureTicker(io)
104 noteStaleSession(io)
105 const tree = await bandTree(io, $.ui.resolve(e), e)
106 return tree ?? next(e)
107 })
108
109 on('ui.render', { component: 'Pane', requestId: 'claude-music' }, async ($, e) => {
110 const io = makeIo($)
111 ensureTicker(io)
112 noteStaleSession(io)
113 if (e.surface === 'mobile') return paneTree(io, $.ui.resolve(e), null, e.props.bodyColumns)
114 const table = $.ui.resolve(e)
115 return paneTree(io, table, table.Input, e.props.bodyColumns)
116 })
117
118 for (const spec of COMMAND_SPECS) {
119 // The handler is async: a command.run hook's catch must answer a promise of the hook's result.
120 on('command.run', { command: spec.name }, ($, e) => runCommand(makeIo($), spec.name, e.args)).catch(async () => ({
121 text: `Claude Music: /${spec.name} failed; see the debug log.`,
122 }))
123 }
124}
125src/band.tsx 169 lines1// The band: Clawd on a deck above the prompt, three rows. Returns null when
2// the prompt should stay clean (the player never played this session and the
3// catalog loaded). Its buttons are plain (`b: prev`), which keeps the three
4// rows within the width the layout plan (layout.ts) allows.
5
6import type { ButtonProps, ElementConstructor, RenderElement, RenderInput } from 'claude-code'
7import { toneOf } from './catalog'
8import type { Table } from './clawd'
9import { CLAWD_COLUMNS, clawdRows, crateColumn, deckRows, gapColumn, volumeBar } from './clawd'
10import { pauseLabel, pauseWord, pose, statusWord } from './clawd-frames'
11import { press, quietly } from './controller'
12import type { Io } from './io'
13import type { ButtonSet } from './layout'
14import { bandPlan } from './layout'
15import { readSnapshot } from './state'
16
17export type BandTable = Table & { Button: ElementConstructor<ButtonProps> }
18
19export async function bandTree(
20 io: Io,
21 T: BandTable,
22 e: RenderInput<'AbovePrompt'>,
23): Promise<RenderElement | null> {
24 const snap = await readSnapshot(io)
25 const { player, anim, session, catalog } = snap
26 if (player.status === 'stopped' && player.station === null && catalog.error === null) return null
27
28 const { Box, Text, Button } = T
29 const width = e.props.bodyColumns
30 const rows = e.props.maxRows
31 const name = player.station !== null ? player.station.name : catalog.error !== null ? 'Claude Music' : 'no station'
32 const genre = player.genre ?? ''
33
34 if (rows < 3) {
35 return (
36 <Box flexDirection="row" gap={1}>
37 <Text bold wrap="truncate-end">
38 {name}
39 </Text>
40 <Button
41 key="bp-pause"
42 hotkey="p"
43 variant="primary"
44 label={pauseLabel(player)}
45 onPress={() => quietly(press(io, { type: 'toggle' }))}
46 />
47 </Box>
48 )
49 }
50
51 const plan = bandPlan(width)
52 const p = pose({
53 player,
54 anim,
55 tick: anim.tick,
56 glyphSet: session.glyphSet,
57 toneOf: g => toneOf(catalog, g),
58 })
59
60 const parts: RenderElement[] = []
61 parts.push(
62 <Box flexDirection="column" width={CLAWD_COLUMNS}>
63 {clawdRows(T, p)}
64 </Box>,
65 )
66 parts.push(gapColumn(T))
67 if (plan.showDeck) {
68 parts.push(
69 <Box flexDirection="column" width={7}>
70 {deckRows(T, p)}
71 </Box>,
72 )
73 parts.push(gapColumn(T))
74 parts.push(crateColumn(T, p))
75 parts.push(gapColumn(T))
76 }
77
78 const titleRow = (
79 <Box flexDirection="row">
80 <Box flexShrink={1}>
81 <Text bold wrap="truncate-end">
82 {name}
83 </Text>
84 </Box>
85 <Text dimColor>{genre !== '' ? ` · ${genre}` : ''}</Text>
86 </Box>
87 )
88
89 // With no station and a catalog error, the error is the status.
90 const isError = player.status === 'error' || (catalog.error !== null && player.station === null)
91 const statusText =
92 catalog.error !== null && player.station === null
93 ? `sources: ${catalog.error}`
94 : statusWord(player, anim, anim.tick)
95 const meterRow = (
96 <Box flexDirection="row">
97 <Box flexShrink={1}>
98 <Text color={isError ? 'error' : undefined} wrap="truncate-end">
99 {statusText}
100 </Text>
101 </Box>
102 <Text>{' '}</Text>
103 {volumeBar(T, player)}
104 </Box>
105 )
106
107 const prev = <Button key="bp-prev" hotkey="b" plain label="prev" onPress={() => quietly(press(io, { type: 'prev' }))} />
108 const pauseButton = (
109 <Button
110 key="bp-pause"
111 hotkey="p"
112 plain
113 variant="primary"
114 label={pauseWord(player)}
115 onPress={() => quietly(press(io, { type: 'toggle' }))}
116 />
117 )
118 const next = <Button key="bp-next" hotkey="n" plain label="next" onPress={() => quietly(press(io, { type: 'next' }))} />
119 const mute = (
120 <Button
121 key="bp-mute"
122 hotkey="m"
123 plain
124 label={player.muted ? 'unmute' : 'mute'}
125 onPress={() => quietly(press(io, { type: 'mute' }))}
126 />
127 )
128 const down = (
129 <Button
130 key="bp-down"
131 hotkey="d"
132 plain
133 label="vol-"
134 onPress={() => quietly(press(io, { type: 'volumeStep', delta: -10 }))}
135 />
136 )
137 const up = (
138 <Button
139 key="bp-up"
140 hotkey="u"
141 plain
142 label="vol+"
143 onPress={() => quietly(press(io, { type: 'volumeStep', delta: 10 }))}
144 />
145 )
146 const buttons: Record<ButtonSet, RenderElement[]> = {
147 pause: [pauseButton],
148 three: [prev, pauseButton, next],
149 four: [prev, pauseButton, next, mute],
150 six: [prev, pauseButton, next, mute, down, up],
151 }
152
153 const buttonRow = (
154 <Box flexDirection="row" gap={1}>
155 {buttons[plan.buttons]}
156 </Box>
157 )
158
159 parts.push(
160 <Box flexDirection="column" flexGrow={1}>
161 {titleRow}
162 {meterRow}
163 {buttonRow}
164 </Box>,
165 )
166
167 return <Box flexDirection="row">{parts}</Box>
168}
169src/commands.ts 235 lines1// Slash commands: /music with its subcommands, and the old names (/play,
2// /stop, /volume and so on) served by the same router. Every handler answers
3// at once; slow work (yt-dlp, spawn) is started by the controller in the back.
4
5import {
6 actFocus,
7 actMood,
8 actPlayQuery,
9 actShuffle,
10 actVibe,
11 press,
12 resetPrefsNow,
13} from './controller'
14import type { Io } from './io'
15import { noPlayText, PANE_ID, readSnapshot } from './state'
16
17type Spec = {
18 name: string
19 description: string
20 argumentHint?: string
21 immediate: boolean
22}
23
24export const COMMAND_SPECS: readonly Spec[] = [
25 {
26 name: 'music',
27 description: 'Claude Music: open the pane, or a subcommand (play, stop, vol, mood, focus, help...)',
28 argumentHint: '[pane|play|stop|pause|next|prev|vol|mute|status|list|mood|vibe|shuffle|focus|reset|help]',
29 immediate: true,
30 },
31 { name: 'play', description: 'Play a genre or station (/music play)', argumentHint: '[genre|station]', immediate: false },
32 { name: 'stop', description: 'Stop the music; the station is kept for resume', immediate: true },
33 { name: 'pause', description: 'Pause, or resume the paused station', immediate: true },
34 { name: 'next', description: 'Next station in this genre', immediate: true },
35 { name: 'prev', description: 'Back to the previous station', immediate: true },
36 { name: 'volume', description: 'Volume 0-100, or up and down', argumentHint: '<0-100|up|down>', immediate: true },
37 { name: 'mute', description: 'Mute or unmute the music', immediate: true },
38 { name: 'mood', description: 'Play music that fits a mood', argumentHint: '<text>', immediate: false },
39 { name: 'vibe', description: 'A random genre, weighted by its stations', immediate: false },
40 { name: 'dj', description: 'A random genre, weighted by its stations (as /vibe)', immediate: false },
41 { name: 'shuffle', description: 'A random genre, then a random station', immediate: false },
42 { name: 'focus', description: 'Focus timer with music; a chime when time is up', argumentHint: '[minutes]', immediate: false },
43 { name: 'pomodoro', description: 'Same as /focus', argumentHint: '[minutes]', immediate: false },
44 { name: 'list', description: 'List the genres and their stations', immediate: true },
45 { name: 'nowplaying', description: 'What is playing now, in one line', immediate: true },
46]
47
48// The old names, each served by one /music subcommand.
49export const ALIAS_SUBCOMMAND: Readonly<Record<string, string>> = {
50 play: 'play',
51 stop: 'stop',
52 pause: 'pause',
53 next: 'next',
54 prev: 'prev',
55 volume: 'vol',
56 mute: 'mute',
57 mood: 'mood',
58 vibe: 'vibe',
59 dj: 'vibe',
60 shuffle: 'shuffle',
61 focus: 'focus',
62 pomodoro: 'focus',
63 list: 'list',
64 nowplaying: 'status',
65}
66
67export const HELP_TEXT = [
68 'Claude Music',
69 '/music open the pane',
70 '/music play [genre|station] a genre (random station), or a station by name',
71 '/music stop | pause | next | prev',
72 '/music vol <0-100|up|down> volume; /music mute, status, list',
73 '/music mood <text> e.g. /music mood rainy focus',
74 '/music vibe | shuffle a random genre',
75 '/music focus [minutes] timer, 1-120 (default 25); a chime when done',
76 '/music reset default volume and last station; totals kept',
77 'Band keys (after ctrl+x tab): b prev, p play or pause, n next, m mute, d vol-, u vol+',
78 'Pane keys: Tab walks the buttons, 1-8 pick a genre, p s n b m u d controls, f c l e mood chips, Esc closes (music keeps playing)',
79 'Pause and resume restart the stream (live radio has no position); volume and mute restart it after a short gap.',
80 'Old names also work: /play /stop /pause /next /prev /volume /mute /mood /vibe /dj /shuffle /focus /pomodoro /list /nowplaying',
81].join('\n')
82
83// Each name is registered on its own: a refused name is logged to the debug
84// log and skipped, and the others still work.
85export async function registerCommands(io: Io): Promise<void> {
86 for (const spec of COMMAND_SPECS) {
87 try {
88 await io.registerCommand({
89 name: spec.name,
90 description: spec.description,
91 ...(spec.argumentHint !== undefined ? { argumentHint: spec.argumentHint } : {}),
92 ...(spec.immediate ? { immediate: true as const } : {}),
93 })
94 } catch (err) {
95 try {
96 io.debug(`command /${spec.name} was refused: ${String(err)}`)
97 } catch {
98 // debug only
99 }
100 }
101 }
102}
103
104export async function openPane(io: Io): Promise<string> {
105 const snap = await readSnapshot(io)
106 let outcome: string
107 try {
108 const opened = await io.open({ id: PANE_ID, title: 'Claude Music', rows: 20, columns: 60 })
109 outcome = opened.isPlaced ? 'Claude Music opened.' : `Claude Music waits: ${opened.reason}`
110 } catch (err) {
111 outcome = `Claude Music could not open: ${String(err)}`
112 }
113 return snap.session.surface !== 'mobile' ? outcome : `${outcome} ${noPlayText(snap.session)}`
114}
115
116function elapsed(startedAt: number | null, nowMs: number): string {
117 const seconds = startedAt === null ? 0 : Math.max(0, Math.floor((nowMs - startedAt) / 1000))
118 const minutes = Math.floor(seconds / 60)
119 const rest = String(seconds % 60).padStart(2, '0')
120 return `${minutes}:${rest}`
121}
122
123async function status(io: Io): Promise<string> {
124 const snap = await readSnapshot(io)
125 const p = snap.player
126 const parts: string[] = [`status ${p.status}`, `genre ${p.genre ?? 'none'}`, `station ${p.station?.name ?? 'none'}`]
127 if (p.station !== null) {
128 parts.push(p.station.kind === 'file' ? `elapsed ${elapsed(p.startedAt, snap.anim.nowMs)}` : 'LIVE')
129 }
130 parts.push(`vol ${p.volume}`, p.muted ? 'muted' : 'unmuted')
131 parts.push(`session ${Math.round(snap.session.sessionMinutes)} min`)
132 parts.push(`total ${Math.round(snap.session.totalMinutes)} min in ${snap.session.totalSessions} sessions`)
133 if (snap.session.surface === 'mobile') parts.push(noPlayText(snap.session))
134 return parts.join(' · ')
135}
136
137async function list(io: Io): Promise<string> {
138 const { catalog } = await readSnapshot(io)
139 if (catalog.error !== null && catalog.genres.length === 0) {
140 return `Catalog error in config/sources.yml: ${catalog.error}`
141 }
142 const lines = catalog.genres.map(g => `${g.name} (${g.stations.length}): ${g.stations.map(s => s.name).join(', ')}`)
143 if (lines.length === 0) return 'No genres loaded from config/sources.yml.'
144 return lines.join('\n')
145}
146
147async function pauseOrResume(io: Io): Promise<string> {
148 const { player } = await readSnapshot(io)
149 if (player.intent === 'play') return press(io, { type: 'pause' })
150 if (player.intent === 'pause' && player.station !== null) return press(io, { type: 'resume' })
151 return 'Nothing is playing.'
152}
153
154async function volume(io: Io, rest: string): Promise<string> {
155 const arg = rest.trim().toLowerCase()
156 if (arg === '') {
157 const { player } = await readSnapshot(io)
158 return `Volume: ${player.volume}/100${player.muted ? ' (muted)' : ''}`
159 }
160 if (arg === 'up') return press(io, { type: 'volumeStep', delta: 10 })
161 if (arg === 'down') return press(io, { type: 'volumeStep', delta: -10 })
162 if (!/^\d+$/.test(arg)) return 'Usage: /music vol <0-100|up|down>'
163 const level = Number(arg)
164 if (level < 0 || level > 100) return 'Usage: /music vol <0-100|up|down>'
165 return press(io, { type: 'setVolume', volume: level })
166}
167
168async function focus(io: Io, rest: string): Promise<string> {
169 let minutes = 25
170 if (rest.trim() !== '') {
171 const requested = Number(rest.trim())
172 if (!Number.isFinite(requested)) return 'Usage: /music focus [minutes 1-120]'
173 minutes = Math.min(120, Math.max(1, Math.round(requested)))
174 }
175 return actFocus(io, minutes)
176}
177
178async function music(io: Io, sub: string, rest: string): Promise<string> {
179 switch (sub) {
180 case '':
181 case 'pane':
182 return openPane(io)
183 case 'play':
184 return rest === '' ? press(io, { type: 'playDefault' }) : actPlayQuery(io, rest)
185 case 'stop':
186 return press(io, { type: 'stop' })
187 case 'pause':
188 return pauseOrResume(io)
189 case 'next':
190 return press(io, { type: 'next' })
191 case 'prev':
192 return press(io, { type: 'prev' })
193 case 'vol':
194 return volume(io, rest)
195 case 'mute':
196 return press(io, { type: 'mute' })
197 case 'status':
198 return status(io)
199 case 'list':
200 return list(io)
201 case 'mood':
202 return rest === '' ? 'Usage: /music mood <text>' : actMood(io, rest)
203 case 'vibe':
204 return actVibe(io)
205 case 'shuffle':
206 return actShuffle(io)
207 case 'focus':
208 return focus(io, rest)
209 case 'reset':
210 return resetPrefsNow(io)
211 case 'help':
212 return HELP_TEXT
213 default:
214 return `Unknown subcommand "${sub}". Try /music help.`
215 }
216}
217
218// The command.run hook's body for every registered name.
219export async function runCommand(io: Io, name: string, rawArgs: string): Promise<{ text: string }> {
220 const args = rawArgs.trim()
221 try {
222 if (name === 'music') {
223 const space = args.search(/\s/)
224 const sub = space < 0 ? args : args.slice(0, space)
225 const rest = space < 0 ? '' : args.slice(space + 1).trim()
226 return { text: await music(io, sub.toLowerCase(), rest) }
227 }
228 const sub = ALIAS_SUBCOMMAND[name]
229 if (sub === undefined) return { text: `Unknown command /${name}.` }
230 return { text: await music(io, sub, args) }
231 } catch (err) {
232 return { text: `Claude Music: ${String(err)}` }
233 }
234}
235src/controller.ts 976 lines1// The controller: the one module that performs I/O (state writes, the store,
2// ffplay and yt-dlp, taskkill, the clock, toasts and the status line), through
3// the Io door. Every change of playback goes through applyAction(), which runs
4// the pure reducer. Every mutation runs inside serial(), so a press, a tick
5// and a child's exit never interleave. yt-dlp runs outside the queue so a slow
6// lookup never holds up a Stop press.
7//
8// A hot reload starts a fresh module and does not fire session.start again. So
9// ensureEnv() runs once per environment, before its first change: it loads the
10// stored prefs, restarts the ticker, restores a station that was playing as
11// paused (the engine has ended its child) and re-arms a running focus timer.
12
13import type { Player, Prefs, Station, Tools } from '../types'
14import { findGenre, findStation, initialCatalog, parseSources, pickUniformGenre, pickVibeGenre } from './catalog'
15import type { Io, Spawned, Timer } from './io'
16import { pickMoodGenre } from './moods'
17import { DEFAULT_PREFS, mergePrefs, resetPrefs } from './prefs'
18import { advanceAnim, expireError, IDLE_ATTEMPT, NO_FFPLAY_MESSAGE, QUICK_FAIL_MS, reduce } from './reducer'
19import type { Action, Attempt, Core, Ctx, Result } from './reducer'
20import { INITIAL_TOOLS, noPlayText, readSnapshot, surfacePlays } from './state'
21import { isStartFailure, killSucceeded, minutesBetween, parseWhereOutput } from './tools'
22
23export const TICK_MS = 200
24export const BAD_TTL_MS = 10 * 60 * 1000
25export const YT_TTL_MS = 10 * 60 * 1000
26const YT_TIMEOUT_MS = 25000
27const VOLUME_DEBOUNCE_MS = 400
28const KILL_TIMEOUT_MS = 3000
29const END_KILL_TIMEOUT_MS = 1000
30const KILL_RETRY_TICKS = 5
31const KILL_MAX_TRIES = 4
32const KILL_FAILED_NOTICE = 'Could not stop ffplay yet; retrying.'
33const KILL_GAVE_UP_MESSAGE = 'ffplay did not stop; press p to try again.'
34
35type Child = { key: string; volume: number; gen: number; startedAt: number }
36
37let child: Child | null = null
38let genSeq = 0
39// Names this copy of the module. The session atom holds the owner's name, so a
40// copy left behind by a hot reload sees another owner and stays idle.
41const OWNER = `${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`
42let queue: Promise<unknown> = Promise.resolve()
43let tickQueued = false
44let ticker: Timer | null = null
45let focusTimer: Timer | null = null
46let focusToken: object | null = null
47let volumeTimer: Timer | null = null
48let volumeSettling = false
49let attempt: Attempt = IDLE_ATTEMPT
50let prefs: Prefs = DEFAULT_PREFS
51let prefsReady = false
52let prefsTried = false
53let statusLine: string | null = null
54let envReady = false
55let sessionEnded = false
56let killPending = false
57let killWait = 0
58let killTries = 0
59let lastClock = 0
60let ytMissingToasted = false
61const bad = new Map<string, number>()
62const ytCache = new Map<string, { url: string; at: number }>()
63const resolving = new Set<string>()
64
65function serial<T>(task: () => Promise<T>): Promise<T> {
66 const run = queue.then(task, task)
67 queue = run.catch(() => undefined)
68 return run
69}
70
71// Resolves once every task queued so far has run (tests, and callers that need
72// the queue flushed).
73export function drainQueue(): Promise<void> {
74 return serial(async () => undefined)
75}
76
77// The session atom names the copy that owns playback. A copy that is not the
78// owner (an older copy, after a hot reload) does nothing; no owner yet means
79// nobody has claimed it, so the copy that runs first proceeds.
80async function isCurrentOwner(io: Io): Promise<boolean> {
81 try {
82 const { owner } = await io.session.read()
83 return owner === null || owner === OWNER
84 } catch {
85 return true
86 }
87}
88
89async function claimOwner(io: Io): Promise<void> {
90 try {
91 await io.session.update(s => (s.owner === OWNER ? s : { ...s, owner: OWNER }))
92 } catch {
93 // the atom refused: this copy plays on, unclaimed
94 }
95}
96
97// serial() for a callback an earlier copy of this module may still run after a
98// hot reload: it does nothing once a newer copy has claimed the session.
99function serialOwned<T>(io: Io, task: () => Promise<T>): Promise<T | undefined> {
100 return serial(async () => ((await isCurrentOwner(io)) ? task() : undefined))
101}
102
103// For a task nobody awaits: its failure is handled here, never left unhandled.
104export function quietly(task: Promise<unknown>): void {
105 task.catch(() => undefined)
106}
107
108function sameJson(a: unknown, b: unknown): boolean {
109 return JSON.stringify(a) === JSON.stringify(b)
110}
111
112// The clock's reading. When the clock fails, the last reading stands, so a
113// missing clock never turns into the epoch (a start time of 0).
114async function clockNow(io: Io): Promise<number> {
115 try {
116 lastClock = await io.now()
117 } catch {
118 // keep lastClock
119 }
120 return lastClock
121}
122
123function toast(io: Io, text: string, timeoutMs: number): void {
124 try {
125 io.toast(text, timeoutMs)
126 } catch {
127 // the surface may be gone (a -p run); playback does not depend on it
128 }
129}
130
131function debugLine(io: Io, text: string): void {
132 try {
133 io.debug(text)
134 } catch {
135 // the debug log is optional
136 }
137}
138
139function effectiveVolume(player: Player): number {
140 return player.muted ? 0 : player.volume
141}
142
143function pluginPath(io: Io, relative: string): string {
144 return `${io.pluginRoot}/${relative}`
145}
146
147function activeBad(now: number): string[] {
148 const keys: string[] = []
149 for (const [key, until] of bad) {
150 if (until > now) keys.push(key)
151 else bad.delete(key)
152 }
153 return keys
154}
155
156function isPlayAction(action: Action): boolean {
157 return (
158 action.type === 'playGenre' ||
159 action.type === 'playStation' ||
160 action.type === 'playDefault' ||
161 action.type === 'resume' ||
162 action.type === 'toggle' ||
163 action.type === 'next' ||
164 action.type === 'prev'
165 )
166}
167
168// --- prefs, tools, mirrors -------------------------------------------------
169
170// Reads the store's prefs. A failed read leaves prefs unready: nothing is
171// written over the stored values until a read succeeds.
172async function loadPrefs(io: Io): Promise<void> {
173 prefsTried = true
174 try {
175 prefs = mergePrefs(await io.getStore('prefs'))
176 prefsReady = true
177 } catch {
178 prefsReady = false
179 }
180}
181
182// Loads the stored prefs once per environment, before any write, so a fresh
183// module never writes its defaults over the stored totals.
184async function ensurePrefs(io: Io): Promise<void> {
185 if (!prefsReady && !prefsTried) await loadPrefs(io)
186}
187
188async function mirrorSession(io: Io): Promise<void> {
189 await io.session.update(s => ({
190 ...s,
191 totalMinutes: prefs.totals.minutes,
192 totalSessions: prefs.totals.sessions,
193 glyphSet: prefs.glyphSet,
194 }))
195}
196
197// Changes prefs from the current values, then writes them when the store was
198// read. Without a readable store the values hold for this session only.
199async function updatePrefs(io: Io, change: (current: Prefs) => Partial<Prefs>): Promise<void> {
200 await ensurePrefs(io)
201 prefs = { ...prefs, ...change(prefs) }
202 if (prefsReady) {
203 try {
204 await io.setStore('prefs', prefs)
205 } catch {
206 // the store refused the write: the values still hold for this session
207 }
208 }
209 await mirrorSession(io)
210}
211
212async function patchTools(io: Io, patch: Partial<Tools>): Promise<void> {
213 await io.tools.update(t => ({ ...t, ...patch }))
214}
215
216// A Windows tool by its full path under the system folder. A bare name would
217// also be searched in the current directory, where a project's own copy could sit.
218async function systemExe(io: Io, name: string): Promise<string> {
219 let root: string | undefined
220 try {
221 root = await io.envSystemRoot()
222 } catch {
223 root = undefined
224 }
225 const base = root !== undefined && root.trim() !== '' ? root.trim().replace(/[\\/]+$/, '') : 'C:\\Windows'
226 return `${base}\\System32\\${name}`
227}
228
229async function fileExists(io: Io, path: string): Promise<boolean> {
230 try {
231 return await io.exists(path)
232 } catch {
233 return false
234 }
235}
236
237// where.exe with the $PATH: prefix searches PATH only (the bare form also
238// searches the current directory first). It writes the console code page when
239// its output is piped, so a non-ASCII path can come back garbled: a path that is
240// not a file is not trusted, and the PATH scan below finds the file instead.
241async function whereExe(io: Io, name: string): Promise<string | null> {
242 try {
243 const where = await systemExe(io, 'where.exe')
244 const result = await io.run([where, `$PATH:${name}.exe`], 10000)
245 if (result.exitCode !== 0) return null
246 return parseWhereOutput(result.stdout)
247 } catch {
248 return null
249 }
250}
251
252// The first PATH folder that holds the file, read with the engine's own strings.
253// Relative PATH entries are skipped, so the current directory is never searched.
254async function scanPath(io: Io, file: string): Promise<string | null> {
255 let raw: string | undefined
256 try {
257 raw = await io.envPath()
258 } catch {
259 return null
260 }
261 if (raw === undefined) return null
262 const folders = raw
263 .split(';')
264 .map(folder => folder.trim().replace(/^"(.*)"$/, '$1').replace(/[\\/]+$/, ''))
265 .filter(folder => /^[A-Za-z]:[\\/]/.test(folder))
266 .slice(0, 64)
267 for (const folder of folders) {
268 const candidate = `${folder}\\${file}`
269 if (await fileExists(io, candidate)) return candidate
270 }
271 return null
272}
273
274async function locate(io: Io, name: string): Promise<string | null> {
275 const fromWhere = await whereExe(io, name)
276 if (fromWhere !== null && (await fileExists(io, fromWhere))) return fromWhere
277 return scanPath(io, `${name}.exe`)
278}
279
280// Looks for both tools. Reads only: the caller writes the result inside serial().
281async function findTools(io: Io): Promise<{ ffplay: string | null; ytdlp: string | null }> {
282 const ffplay = await locate(io, 'ffplay')
283 const ytdlp = await locate(io, 'yt-dlp')
284 return { ffplay, ytdlp }
285}
286
287// Stores what findTools found. Runs inside serial().
288async function writeTools(io: Io, found: { ffplay: string | null; ytdlp: string | null }): Promise<void> {
289 await patchTools(io, { ffplay: found.ffplay !== null, ytdlp: found.ytdlp !== null })
290 await updatePrefs(io, () => ({ ffplayPath: found.ffplay, ytdlpPath: found.ytdlp }))
291}
292
293// At session start, the lookup runs outside the queue; the write waits its turn.
294export async function probeTools(io: Io): Promise<void> {
295 const found = await findTools(io)
296 await serial(() => writeTools(io, found))
297}
298
299// --- status line ------------------------------------------------------------
300
301function statusLineFor(player: Player): string | null {
302 if (player.status === 'loading') return 'tuning in…'
303 if (player.status === 'playing') return `playing ${player.station !== null ? player.station.name : ''}`.trim()
304 if (player.status === 'paused') return 'paused'
305 if (player.status === 'error') return `error: ${player.message ?? 'playback failed'}`
306 return null
307}
308
309function syncStatus(io: Io, player: Player): void {
310 const line = statusLineFor(player)
311 if (line === statusLine) return
312 statusLine = line
313 try {
314 io.status(line === null ? undefined : `Claude Music: ${line}`)
315 } catch {
316 // the status line is optional
317 }
318}
319
320// --- the child process -----------------------------------------------------
321
322// taskkill /F /IM ffplay.exe. True when it ended one, or none was running.
323async function killAll(io: Io, timeoutMs: number): Promise<boolean> {
324 try {
325 const taskkill = await systemExe(io, 'taskkill.exe')
326 const result = await io.run([taskkill, '/F', '/IM', 'ffplay.exe'], timeoutMs)
327 return killSucceeded(result.exitCode)
328 } catch {
329 return false
330 }
331}
332
333// A kill that failed is tried again about once a second, and nothing new starts
334// meanwhile. After KILL_MAX_TRIES failed tries the player pauses with a message,
335// and the next press starts over. True when no kill is pending.
336async function retryKill(io: Io): Promise<boolean> {
337 if (!killPending) return true
338 killWait += 1
339 if (killWait < KILL_RETRY_TICKS) return false
340 killWait = 0
341 killTries += 1
342 if (await killAll(io, KILL_TIMEOUT_MS)) {
343 killPending = false
344 killTries = 0
345 await io.pane.update(p => ({ ...p, notice: p.notice === KILL_FAILED_NOTICE ? null : p.notice }))
346 return true
347 }
348 if (killTries < KILL_MAX_TRIES) return false
349 killPending = false
350 killTries = 0
351 await applyAction(io, { type: 'fail', message: KILL_GAVE_UP_MESSAGE, intent: 'pause' })
352 await io.pane.update(p => ({ ...p, notice: KILL_GAVE_UP_MESSAGE }))
353 return false
354}
355
356// Counts a child's minutes into the session and the stored totals.
357async function accrueSince(io: Io, startedAt: number, now: number): Promise<void> {
358 const minutes = minutesBetween(startedAt, now)
359 if (minutes <= 0) return
360 await io.session.update(s => ({ ...s, sessionMinutes: s.sessionMinutes + minutes }))
361 await updatePrefs(io, current => ({ totals: { ...current.totals, minutes: current.totals.minutes + minutes } }))
362}
363
364// Ends the current child: kill it, then count its minutes. A kill that fails
365// stays pending and is retried; nothing new starts while it is.
366async function stopChild(io: Io): Promise<void> {
367 const ended = child
368 child = null
369 if (!(await killAll(io, KILL_TIMEOUT_MS))) {
370 killPending = true
371 killWait = 0
372 killTries = 0
373 await io.pane.update(p => ({ ...p, notice: KILL_FAILED_NOTICE }))
374 }
375 if (ended === null) return
376 await accrueSince(io, ended.startedAt, await clockNow(io))
377}
378
379async function startChild(io: Io, station: Station, url: string, eff: number, now: number): Promise<void> {
380 // No bare-name fallback: a path that was not found on PATH is not guessed.
381 const ffplay = prefs.ffplayPath
382 let stream: Spawned | null = null
383 if (ffplay !== null) {
384 const argv = [ffplay, '-nodisp', '-hide_banner', '-loglevel', 'error', '-volume', String(eff)]
385 if (station.kind === 'file') argv.push('-loop', '0')
386 argv.push(url)
387 try {
388 stream = io.spawn(argv)
389 } catch {
390 stream = null
391 }
392 }
393 if (stream === null) {
394 await patchTools(io, { ffplay: false })
395 await applyAction(io, { type: 'streamEnded', key: station.key, ranMs: 0, cannotStart: true })
396 return
397 }
398 const gen = ++genSeq
399 child = { key: station.key, volume: eff, gen, startedAt: now }
400 // One session is counted per start from stopped; a pause or a hot reload does not count again.
401 if (!(await io.session.read()).sessionCounted) {
402 await io.session.update(s => ({ ...s, sessionCounted: true }))
403 await updatePrefs(io, current => ({ totals: { ...current.totals, sessions: current.totals.sessions + 1 } }))
404 }
405 await io.player.update(p => (p.intent === 'play' ? { ...p, status: 'playing', startedAt: now, message: null } : p))
406 quietly(watchChild(io, stream, gen, station, now))
407 syncStatus(io, (await readSnapshot(io)).player)
408}
409
410// Drains the child's output and, when it ends on its own, hands the outcome
411// to the reducer. A child we killed or replaced has another gen and is ignored.
412async function watchChild(io: Io, stream: Spawned, gen: number, station: Station, startedAt: number): Promise<void> {
413 let pulled = false
414 let threw = false
415 let lastLine = ''
416 try {
417 for await (const piece of stream) {
418 pulled = true
419 const lines = piece.text
420 .split(/\r?\n/)
421 .map(line => line.trim())
422 .filter(line => line.length > 0)
423 if (lines.length > 0) lastLine = lines[lines.length - 1] ?? lastLine
424 }
425 } catch {
426 threw = true
427 }
428 try {
429 await stream.result
430 } catch {
431 // the stream was closed before its end; the loop above already settled
432 }
433 await serialOwned(io, async () => {
434 if (child === null || child.gen !== gen) return
435 child = null
436 if (lastLine.length > 0) debugLine(io, `ffplay: ${lastLine}`)
437 const now = await clockNow(io)
438 const ranMs = now - startedAt
439 const cannotStart = threw && !pulled
440 if (cannotStart) await patchTools(io, { ffplay: false })
441 else if (ranMs < QUICK_FAIL_MS) bad.set(station.key, now + BAD_TTL_MS)
442 await accrueSince(io, startedAt, now)
443 await applyAction(io, { type: 'streamEnded', key: station.key, ranMs, cannotStart })
444 await reconcileNow(io)
445 })
446}
447
448// --- youtube pages -----------------------------------------------------------
449
450function missingYtdlpToast(io: Io): void {
451 if (ytMissingToasted) return
452 ytMissingToasted = true
453 toast(io, 'yt-dlp not found: YouTube stations are skipped. Install it, e.g. winget install yt-dlp.yt-dlp', 5000)
454}
455
456async function runYtdlp(io: Io, station: Station): Promise<string | null> {
457 const ytdlp = prefs.ytdlpPath
458 if (ytdlp === null) {
459 await patchTools(io, { ytdlp: false })
460 missingYtdlpToast(io)
461 return null
462 }
463 const started = await clockNow(io)
464 try {
465 const result = await io.run([ytdlp, '--no-playlist', '-g', '-f', 'bestaudio/worst', station.url], YT_TIMEOUT_MS)
466 if (result.exitCode !== 0) return null
467 const line = result.stdout
468 .split(/\r?\n/)
469 .map(text => text.trim())
470 .find(text => text.length > 0)
471 if (line === undefined) return null
472 await patchTools(io, { ytdlp: true })
473 return line
474 } catch {
475 // $.process.run rejects at its timeout too: only a quick rejection means
476 // yt-dlp did not start; a slow one is a lookup that timed out.
477 const ended = await clockNow(io)
478 if (isStartFailure(ended - started, YT_TIMEOUT_MS)) {
479 await patchTools(io, { ytdlp: false })
480 missingYtdlpToast(io)
481 }
482 return null
483 }
484}
485
486function resolveInBackground(io: Io, station: Station): void {
487 if (resolving.has(station.key)) return
488 resolving.add(station.key)
489 void (async () => {
490 const url = await runYtdlp(io, station)
491 const now = await clockNow(io)
492 await serialOwned(io, async () => {
493 resolving.delete(station.key)
494 if (url !== null) {
495 ytCache.set(station.url, { url, at: now })
496 } else {
497 // A missing yt-dlp is not the station's fault, so it is not marked bad.
498 if ((await io.tools.read()).ytdlp !== false) bad.set(station.key, now + BAD_TTL_MS)
499 await applyAction(io, { type: 'streamEnded', key: station.key, ranMs: 0, cannotStart: false })
500 }
501 await reconcileNow(io)
502 })
503 })().catch(() => {
504 resolving.delete(station.key)
505 })
506}
507
508// --- reconciler ------------------------------------------------------------
509
510// Brings the child in line with the player: a stop or pause kills it, a play
511// starts the station once the record is down (swapStep 0), and a volume or
512// mute change restarts it (volume after the debounce). Runs inside serial().
513async function reconcileNow(io: Io): Promise<void> {
514 if (sessionEnded) return
515 await ensureEnv(io)
516 const snap = await readSnapshot(io)
517 if (snap.session.surface === 'mobile') return
518 if (snap.tools.ffplay === null) return
519 const player = snap.player
520 if (snap.tools.ffplay === false) {
521 if (player.intent === 'play') await applyAction(io, { type: 'fail', message: NO_FFPLAY_MESSAGE, intent: 'pause' })
522 return
523 }
524 if (!(await retryKill(io))) return
525 if (player.intent !== 'play' || player.station === null) {
526 if (child !== null) await stopChild(io)
527 return
528 }
529 const station = player.station
530 // A stream that ended on its own keeps its error on show for the error's window
531 // before the retry starts.
532 if (player.status === 'error' && snap.anim.tick < snap.anim.errorUntilTick) return
533 const eff = effectiveVolume(player)
534 if (child !== null && child.key === station.key && (child.volume === eff || volumeSettling)) return
535
536 const now = await clockNow(io)
537 let url: string
538 if (station.kind === 'youtube') {
539 const cached = ytCache.get(station.url)
540 if (cached !== undefined && now - cached.at < YT_TTL_MS) {
541 url = cached.url
542 } else {
543 // yt-dlp was missing at the last probe: the station is passed over without a
544 // bad mark, so it comes back once a play press finds yt-dlp.
545 if (snap.tools.ytdlp === false) {
546 await applyAction(io, { type: 'streamEnded', key: station.key, ranMs: 0, cannotStart: false })
547 return
548 }
549 resolveInBackground(io, station)
550 return
551 }
552 } else if (station.kind === 'file') {
553 url = pluginPath(io, station.url)
554 } else {
555 url = station.url
556 }
557 if (snap.anim.swapStep !== 0) return
558 if (child !== null) await stopChild(io)
559 // A kill that failed leaves the old stream possibly playing: nothing new
560 // starts until it is confirmed gone. The session may have ended during the
561 // kill; the check and startChild's spawn have no await between them.
562 if (sessionEnded || killPending) return
563 await startChild(io, station, url, eff, now)
564}
565
566function scheduleReconcile(io: Io): void {
567 quietly(serialOwned(io, () => reconcileNow(io)))
568}
569
570// --- the reducer's door ----------------------------------------------------
571
572async function applyAction(io: Io, action: Action): Promise<string> {
573 let snap = await readSnapshot(io)
574 // On a surface that cannot play nothing plays, so the record does not move
575 // either: the reducer does not run and the pane says why.
576 if (snap.session.surface === 'mobile' && isPlayAction(action)) {
577 const notice = noPlayText(snap.session)
578 await io.pane.update(p => ({ ...p, notice }))
579 return notice
580 }
581 // A tool that was missing at the last probe is looked for again by a play press,
582 // before the reducer decides, so the press that follows an install plays at once.
583 if (isPlayAction(action) && (snap.tools.ffplay === false || snap.tools.ytdlp === false)) {
584 await writeTools(io, await findTools(io))
585 snap = await readSnapshot(io)
586 }
587 const now = await clockNow(io)
588 if (action.type === 'playStation') bad.delete(action.key)
589 const core: Core = { player: snap.player, anim: snap.anim, catalog: snap.catalog, tools: snap.tools }
590 const ctx: Ctx = {
591 rand: Math.random(),
592 rand2: Math.random(),
593 bad: activeBad(now),
594 lastGenre: prefs.lastGenre,
595 lastStationByGenre: prefs.lastStationByGenre,
596 attempt,
597 }
598 const result: Result = reduce(core, action, ctx)
599 attempt = result.attempt
600 if (action.type === 'stop' || action.type === 'focusEnd') {
601 await io.session.update(s => ({ ...s, sessionCounted: false }))
602 }
603 if (!sameJson(result.core.player, snap.player)) await io.player.update(() => result.core.player)
604 if (!sameJson(result.core.anim, snap.anim)) await io.anim.update(() => result.core.anim)
605 if (!sameJson(result.core.tools, snap.tools)) await io.tools.update(() => result.core.tools)
606 if (result.notice !== undefined) {
607 const shown: string | null = result.notice
608 await io.pane.update(p => ({ ...p, notice: shown }))
609 }
610 if (result.toast) toast(io, result.toast.text, result.toast.timeoutMs)
611 const patch = result.prefs
612 if (patch !== undefined) await updatePrefs(io, () => patch)
613 if (result.cancelFocus) await cancelFocusNow(io)
614 if (action.type === 'setVolume' || action.type === 'volumeStep') settleVolume(io)
615 syncStatus(io, result.core.player)
616 return result.text
617}
618
619// The one public door for playback changes: queued, then the child follows.
620export function press(io: Io, action: Action): Promise<string> {
621 return serial(async () => {
622 await ensureEnv(io)
623 const text = await applyAction(io, action)
624 scheduleReconcile(io)
625 return text
626 })
627}
628
629// A volume change restarts the child once the value has settled for 400 ms.
630function settleVolume(io: Io): void {
631 volumeSettling = true
632 if (volumeTimer !== null) volumeTimer.cancel()
633 try {
634 volumeTimer = io.after(VOLUME_DEBOUNCE_MS, () => {
635 volumeTimer = null
636 volumeSettling = false
637 scheduleReconcile(io)
638 })
639 } catch {
640 volumeTimer = null
641 volumeSettling = false
642 }
643}
644
645// --- the 200 ms ticker -----------------------------------------------------
646
647// Starts the ticker unless one runs. Render hooks call it as well: a hot
648// reload drops the old timers, and the next drawing restarts them.
649export function ensureTicker(io: Io): void {
650 if (ticker !== null || sessionEnded) return
651 try {
652 ticker = io.every(TICK_MS, () => notifyTick(io))
653 } catch {
654 ticker = null
655 }
656}
657
658async function onTick(io: Io): Promise<void> {
659 await ensureEnv(io)
660 const now = await clockNow(io)
661 const anim = await io.anim.update(a => advanceAnim(a, now))
662 const current = await io.player.read()
663 if (current.status === 'error' && anim.tick >= anim.errorUntilTick) {
664 const player = await io.player.update(p => expireError(p, anim))
665 syncStatus(io, player)
666 }
667 await reconcileNow(io)
668}
669
670// The ticker's entry: at most one tick waits in the queue, so a slow turn of
671// the engine cannot pile up timers.
672export function notifyTick(io: Io): void {
673 if (tickQueued) return
674 tickQueued = true
675 quietly(
676 serialOwned(io, () => onTick(io)).finally(() => {
677 tickQueued = false
678 }),
679 )
680}
681
682// Runs once per environment, before its first change (see the header).
683async function ensureEnv(io: Io): Promise<void> {
684 if (envReady) return
685 envReady = true
686 await claimOwner(io)
687 ensureTicker(io)
688 await ensurePrefs(io)
689 const snap = await readSnapshot(io)
690 const p = snap.player
691 if (child === null && p.station !== null && (p.status === 'playing' || p.status === 'loading')) {
692 await applyAction(io, { type: 'restore' })
693 }
694 const endsAt = snap.session.focusEndsAt
695 if (endsAt !== null && focusTimer === null) {
696 const left = endsAt - (await clockNow(io))
697 if (left <= 0) await onFocusEnd(io)
698 else armFocus(io, left)
699 }
700}
701
702// --- focus -----------------------------------------------------------------
703
704async function cancelFocusNow(io: Io): Promise<void> {
705 focusToken = null
706 if (focusTimer !== null) {
707 focusTimer.cancel()
708 focusTimer = null
709 }
710 await io.session.update(s => ({ ...s, focusEndsAt: null }))
711}
712
713// Each armed focus timer has its own token. A timer that fires after a stop, or
714// after a newer focus was armed, finds the token replaced and does nothing.
715function armFocus(io: Io, ms: number): boolean {
716 const token: object = {}
717 focusToken = token
718 try {
719 focusTimer = io.after(ms, () => {
720 quietly(serialOwned(io, () => onFocusFired(io, token)))
721 })
722 return true
723 } catch {
724 focusTimer = null
725 focusToken = null
726 return false
727 }
728}
729
730async function onFocusFired(io: Io, token: object): Promise<void> {
731 if (focusToken !== token) return
732 await onFocusEnd(io)
733}
734
735export function startFocus(io: Io, minutes: number): Promise<void> {
736 return serial(async () => {
737 await ensureEnv(io)
738 await cancelFocusNow(io)
739 const now = await clockNow(io)
740 const span = minutes * 60000
741 if (!armFocus(io, span)) return
742 await io.session.update(s => ({ ...s, focusEndsAt: now + span, focusMinutes: minutes }))
743 })
744}
745
746// The focus timer fired: stop the music, then play the chime once, on its
747// own child, so the kill that stops the music cannot take the chime with it.
748async function onFocusEnd(io: Io): Promise<void> {
749 focusTimer = null
750 focusToken = null
751 await io.session.update(s => ({ ...s, focusEndsAt: null }))
752 if (child !== null) await stopChild(io)
753 await applyAction(io, { type: 'focusEnd' })
754 playChime(io)
755}
756
757function playChime(io: Io): void {
758 const ffplay = prefs.ffplayPath
759 if (ffplay === null) return
760 try {
761 const stream = io.spawn([
762 ffplay,
763 '-nodisp',
764 '-autoexit',
765 '-loglevel',
766 'error',
767 '-volume',
768 '60',
769 pluginPath(io, 'assets/chime.wav'),
770 ])
771 quietly(
772 (async () => {
773 for await (const piece of stream) void piece
774 await stream.result
775 })(),
776 )
777 } catch {
778 // no chime when ffplay cannot start; the stop still happened
779 }
780}
781
782// --- the catalog file -------------------------------------------------------
783
784// Reads config/sources.yml. When the file cannot be read, the built-in copy
785// stands in; when it is read but does not parse, the error is shown instead.
786async function loadCatalog(io: Io): Promise<void> {
787 let text: string | null
788 try {
789 text = await io.readText(pluginPath(io, 'config/sources.yml'))
790 } catch {
791 text = null
792 }
793 if (text === null) {
794 await io.catalog.update(() => initialCatalog())
795 debugLine(io, 'config/sources.yml could not be read; the built-in catalog is in use')
796 return
797 }
798 const parsed = parseSources(text)
799 await io.catalog.update(() => ({ genres: parsed.genres, error: parsed.error }))
800}
801
802// --- session ---------------------------------------------------------------
803
804export async function startSession(io: Io, e: { isInteractive: boolean; surface: string | null }): Promise<void> {
805 sessionEnded = false
806 await serial(async () => {
807 if (ticker !== null) ticker.cancel()
808 ticker = null
809 if (focusTimer !== null) focusTimer.cancel()
810 focusTimer = null
811 focusToken = null
812 if (volumeTimer !== null) volumeTimer.cancel()
813 volumeTimer = null
814 volumeSettling = false
815 killPending = false
816 killWait = 0
817 killTries = 0
818 ytMissingToasted = false
819 // The desktop app and the SDK start a session with surface null and isInteractive
820 // false (the surface attaches later), so only mobile, which cannot spawn a player,
821 // is refused.
822 const canPlay = e.surface !== 'mobile'
823 await loadPrefs(io)
824 envReady = true
825 child = null
826 attempt = IDLE_ATTEMPT
827 bad.clear()
828 ytCache.clear()
829 resolving.clear()
830 statusLine = null
831 await loadCatalog(io)
832 await io.tools.update(() => INITIAL_TOOLS)
833 const before = await readSnapshot(io)
834 await io.session.update(s => ({
835 ...s,
836 canPlay,
837 surface: e.surface,
838 focusEndsAt: null,
839 glyphSet: prefs.glyphSet,
840 sessionMinutes: 0,
841 sessionCounted: false,
842 owner: OWNER,
843 }))
844 if (before.player.station === null && before.player.status === 'stopped') {
845 await io.player.update(p => ({ ...p, volume: prefs.volume, muted: prefs.muted }))
846 }
847 await mirrorSession(io)
848 await io.pane.update(p => ({ ...p, notice: canPlay ? null : noPlayText({ canPlay, surface: e.surface }) }))
849 ensureTicker(io)
850 if (before.player.station !== null && (before.player.status === 'playing' || before.player.status === 'loading')) {
851 await applyAction(io, { type: 'restore' })
852 }
853 })
854 quietly(probeTools(io))
855 scheduleReconcile(io)
856}
857
858// A hot reload keeps the session's state but starts no session, so a session an
859// older build started has no boolean canPlay. Its draw shows the update notice
860// instead of the stale one. This only reads and sets the notice; playback still
861// waits for a session.start, the event that decides canPlay.
862export function noteStaleSession(io: Io): void {
863 quietly(
864 serial(async () => {
865 const session = await io.session.read()
866 const pane = await io.pane.read()
867 const stale = pane.notice !== null && /^(Playback is off|Claude Music was updated)/.test(pane.notice)
868 if (session.surface === 'mobile') {
869 const notice = noPlayText({ canPlay: false, surface: 'mobile' })
870 if (pane.notice !== notice) await io.pane.update(p => ({ ...p, notice }))
871 } else if (stale) {
872 await io.pane.update(p => ({ ...p, notice: null }))
873 }
874 }),
875 )
876}
877
878// session.end: a /clear keeps the music going; any other end stops it. The
879// session is marked ended first, so a lookup that finishes later cannot start
880// a child after this.
881export async function endSession(io: Io, reason: string): Promise<void> {
882 if (reason === 'clear') return
883 sessionEnded = true
884 if (ticker !== null) {
885 ticker.cancel()
886 ticker = null
887 }
888 if (focusTimer !== null) {
889 focusTimer.cancel()
890 focusTimer = null
891 }
892 if (volumeTimer !== null) {
893 volumeTimer.cancel()
894 volumeTimer = null
895 }
896 volumeSettling = false
897 const ended = child
898 child = null
899 // The kill comes first: the session's hooks share one short time bound, and a
900 // child that outlives Claude is the failure that matters. Minutes come after.
901 if (ended !== null || killPending) await killAll(io, END_KILL_TIMEOUT_MS)
902 if (ended === null) return
903 try {
904 await accrueSince(io, ended.startedAt, await clockNow(io))
905 } catch {
906 // the session is ending; the totals keep what they had
907 }
908}
909
910// --- compound actions (mood, vibe, shuffle, search, focus, reset) ---------
911
912export async function actMood(io: Io, text: string): Promise<string> {
913 await ensurePrefs(io)
914 const { catalog } = await readSnapshot(io)
915 const trimmed = text.trim()
916 // Only genres the catalog still lists can win a mood.
917 const available = catalog.genres.map(g => g.name)
918 const pick = pickMoodGenre(trimmed, prefs.lastGenre, Math.random(), available)
919 const genre = pick.genre ?? pickVibeGenre(catalog, prefs.lastGenre, Math.random())
920 if (genre === null) return 'The catalog is empty.'
921 const label = pick.matched ? `mood "${trimmed}": ${genre}` : `no mood match; picked ${genre}`
922 return press(io, { type: 'playGenre', genre, label })
923}
924
925export async function actVibe(io: Io): Promise<string> {
926 await ensurePrefs(io)
927 const { catalog } = await readSnapshot(io)
928 const genre = pickVibeGenre(catalog, prefs.lastGenre, Math.random())
929 if (genre === null) return 'The catalog is empty.'
930 return press(io, { type: 'playGenre', genre })
931}
932
933export async function actShuffle(io: Io): Promise<string> {
934 const { catalog } = await readSnapshot(io)
935 const genre = pickUniformGenre(catalog, Math.random())
936 if (genre === null) return 'The catalog is empty.'
937 return press(io, { type: 'playGenre', genre })
938}
939
940export async function actPlayQuery(io: Io, query: string): Promise<string> {
941 const { catalog } = await readSnapshot(io)
942 const genre = findGenre(catalog, query)
943 if (genre !== null) return press(io, { type: 'playGenre', genre })
944 const station = findStation(catalog, query)
945 if (station !== null) return press(io, { type: 'playStation', key: station.key })
946 return `no genre or station ${query}; try /music list`
947}
948
949// The timer runs whether or not music plays; it stops nothing that is not
950// playing.
951export async function actFocus(io: Io, minutes: number): Promise<string> {
952 await startFocus(io, minutes)
953 const { player } = await readSnapshot(io)
954 const tail = player.intent === 'play' ? '' : ' Nothing is playing yet; /music play starts it.'
955 return `Focus for ${minutes} min. The music stops and a chime plays when time is up.${tail}`
956}
957
958export function resetPrefsNow(io: Io): Promise<string> {
959 return serial(async () => {
960 await ensureEnv(io)
961 await ensurePrefs(io)
962 prefs = resetPrefs(prefs)
963 if (prefsReady) {
964 try {
965 await io.setStore('prefs', prefs)
966 } catch {
967 // the store refused; the reset still holds for this session
968 }
969 }
970 await io.player.update(p => ({ ...p, volume: prefs.volume, muted: prefs.muted }))
971 await mirrorSession(io)
972 settleVolume(io)
973 return 'Preferences reset: volume 30, unmuted, no last station. Totals kept.'
974 })
975}
976src/io.ts 62 lines1// The narrow door the modules use to reach the engine. hooks/register.tsx
2// builds one from each hook's $ (the engine's own $ never leaves that file),
3// so every module below stays free of $ and only sees these functions.
4
5import type { Anim, Catalog, PaneState, Player, Session, Tools } from '../types'
6
7export type Timer = { cancel: () => void }
8
9export type Chunk = { stream: 'stdout' | 'stderr'; text: string }
10
11export type Spawned = AsyncIterable<Chunk> & {
12 readonly result: Promise<{ code: number | null; signal: string | null }>
13}
14
15export type RunResult = { exitCode: number; stdout: string; stderr: string }
16
17export type OpenArgs = { id: string; title: string; rows: number; columns: number }
18
19export type OpenOutcome = { isPlaced: boolean; reason: string }
20
21export type CommandSpec = {
22 name: string
23 description: string
24 argumentHint?: string
25 immediate?: true
26}
27
28// One atom of the mod's state: read it, or write it with a change function.
29export type Slot<T> = {
30 read: () => Promise<T>
31 update: (change: (value: T) => T) => Promise<T>
32}
33
34export type Io = {
35 pluginRoot: string
36 // Reads a text file by its absolute path (the plugin's own config).
37 readText: (path: string) => Promise<string>
38 now: () => Promise<number>
39 player: Slot<Player>
40 anim: Slot<Anim>
41 catalog: Slot<Catalog>
42 tools: Slot<Tools>
43 pane: Slot<PaneState>
44 session: Slot<Session>
45 // Whether a file or folder exists at an absolute path (the engine's fs.exists).
46 exists: (path: string) => Promise<boolean>
47 // The process environment's PATH and SystemRoot, when set.
48 envPath: () => Promise<string | undefined>
49 envSystemRoot: () => Promise<string | undefined>
50 run: (argv: readonly string[], timeoutMs: number) => Promise<RunResult>
51 spawn: (argv: readonly string[]) => Spawned
52 getStore: (key: string) => Promise<unknown>
53 setStore: (key: string, value: unknown) => Promise<void>
54 toast: (text: string, timeoutMs: number) => void
55 status: (text: string | undefined) => void
56 debug: (text: string) => void
57 after: (ms: number, fn: () => void) => Timer
58 every: (ms: number, fn: () => void) => Timer
59 open: (args: OpenArgs) => Promise<OpenOutcome>
60 registerCommand: (spec: CommandSpec) => Promise<void>
61}
62src/pane.tsx 181 lines1// The Claude Music pane: header (Clawd, deck, title, badge, volume, totals),
2// genre sleeves, the stations of the current genre, transport, the mood input
3// (not on mobile, which has no Input) and mood chips, then the notice line.
4// It uses no Select, Svg, Raster, Image or Client, so every surface gets the
5// same controls. The engine draws the pane's frame; the body is sized to
6// bodyColumns, never to the viewport.
7
8import type { ButtonProps, ElementConstructor, InputProps, RenderElement, RenderNode } from 'claude-code'
9import { genreStations, toneOf } from './catalog'
10import type { Table } from './clawd'
11import { CLAWD_COLUMNS, clawdRows, gapColumn, volumeBar } from './clawd'
12import { badgeOf, pauseWord, pose } from './clawd-frames'
13import { actMood, press, quietly } from './controller'
14import type { Io } from './io'
15import { noticeColor, readSnapshot } from './state'
16import type { Snapshot } from './state'
17
18export type PaneTable = Table & { Button: ElementConstructor<ButtonProps> }
19export type InputConstructor = ElementConstructor<InputProps>
20
21const MOOD_PLACEHOLDER = 'mood, e.g. rainy focus'
22const HINT = 'Press a genre, or type a mood. Esc closes; the music keeps playing.'
23const STATION_CELLS = 40
24const MIN_STATION_CELLS = 16
25
26function truncate(text: string, cells: number): string {
27 return text.length > cells ? `${text.slice(0, cells - 1)}…` : text
28}
29
30async function submitMood(io: Io, value: string): Promise<void> {
31 try {
32 await io.pane.update(s => ({ ...s, moodDraft: '' }))
33 await actMood(io, value)
34 } catch {
35 // a failed mood pick leaves the notice as it was
36 }
37}
38
39export async function paneTree(
40 io: Io,
41 T: PaneTable,
42 input: InputConstructor | null,
43 bodyColumns: number,
44): Promise<RenderElement> {
45 const snap = await readSnapshot(io)
46 return buildPane(io, T, input, snap, bodyColumns)
47}
48
49function buildPane(
50 io: Io,
51 T: PaneTable,
52 input: InputConstructor | null,
53 snap: Snapshot,
54 bodyColumns: number,
55): RenderElement {
56 const { Box, Text, Button } = T
57 const { player, anim, catalog, session, pane } = snap
58 const p = pose({
59 player,
60 anim,
61 tick: anim.tick,
62 glyphSet: session.glyphSet,
63 toneOf: g => toneOf(catalog, g),
64 })
65 const badge = badgeOf(player, anim, anim.tick)
66 const badgeColor =
67 player.status === 'playing'
68 ? 'success'
69 : player.status === 'paused'
70 ? 'warning'
71 : player.status === 'error'
72 ? 'error'
73 : 'subtle'
74 const stationCells = Math.max(MIN_STATION_CELLS, Math.min(STATION_CELLS, bodyColumns - 8))
75 const stationName = player.station !== null ? player.station.name : 'pick a genre below'
76
77 // Minimal header: Clawd, then three short lines.
78 const headerText = (
79 <Box flexDirection="column" flexGrow={1}>
80 <Box flexDirection="row">
81 <Text bold color="claude">
82 Claude Music
83 </Text>
84 <Text>{' '}</Text>
85 <Text color={badgeColor}>{`${badge.glyph} ${badge.word}`}</Text>
86 </Box>
87 <Text wrap="truncate-end">{stationName}</Text>
88 {volumeBar(T, player)}
89 </Box>
90 )
91 const header = (
92 <Box flexDirection="row">
93 <Box flexDirection="column" width={CLAWD_COLUMNS}>
94 {clawdRows(T, p)}
95 </Box>
96 {gapColumn(T)}
97 {headerText}
98 </Box>
99 )
100
101 // The genre buttons are plain: a plain button draws its key before its label
102 // (`1: lofi (2)`). A bracketed button does not show its hotkey in the terminal,
103 // so a digit kept only as a hotkey would be invisible there. The label carries
104 // no digit, so the key shows once. The tone dot is a Text beside the Button, not
105 // inside it: a Button paints its own label style, which could override a child's
106 // colour. The dot is filled for the genre that plays and hollow for the rest.
107 const sleeves = catalog.genres.map((g, index) => (
108 <Box key={`gs-${g.name}`} flexDirection="row">
109 <Text color={g.tone} bold={player.genre === g.name}>
110 {player.genre === g.name ? '● ' : '○ '}
111 </Text>
112 <Button
113 key={`g-${g.name}`}
114 plain
115 hotkey={index < 8 ? String(index + 1) : undefined}
116 label={`${g.name} (${g.stations.length})`}
117 onPress={() => quietly(press(io, { type: 'playGenre', genre: g.name }))}
118 />
119 </Box>
120 ))
121
122 const stations = player.genre !== null ? genreStations(catalog, player.genre) : []
123 const stationButtons = stations.map((station, index) => {
124 const current = player.station !== null && player.station.key === station.key
125 return (
126 <Button
127 key={`st-${index}`}
128 plain
129 label={truncate(`${current ? '▸' : ' '} ${station.name}`, stationCells)}
130 onPress={() => quietly(press(io, { type: 'playStation', key: station.key }))}
131 />
132 )
133 })
134 const stationsLabel = player.genre !== null ? `stations · ${player.genre}` : 'stations'
135
136 const ctl = (key: string, hotkey: string, label: string, action: Parameters<typeof press>[1]) => (
137 <Button key={`c-${key}`} plain hotkey={hotkey} label={label} onPress={() => quietly(press(io, action))} />
138 )
139 const controls = (
140 <Box flexDirection="row" flexWrap="wrap" gap={1}>
141 {ctl('prev', 'b', 'prev', { type: 'prev' })}
142 {ctl('pause', 'p', pauseWord(player), { type: 'toggle' })}
143 {ctl('next', 'n', 'next', { type: 'next' })}
144 {ctl('mute', 'm', player.muted ? 'unmute' : 'mute', { type: 'mute' })}
145 {ctl('down', 'd', 'vol-', { type: 'volumeStep', delta: -10 })}
146 {ctl('up', 'u', 'vol+', { type: 'volumeStep', delta: 10 })}
147 {ctl('stop', 's', 'stop', { type: 'stop' })}
148 </Box>
149 )
150
151 const children: RenderNode[] = [
152 header,
153 <Box flexDirection="row" flexWrap="wrap" gap={1}>
154 {sleeves}
155 </Box>,
156 <Text dimColor>{stationsLabel}</Text>,
157 <Box flexDirection="column">{stationButtons}</Box>,
158 controls,
159 ]
160 if (input !== null) {
161 children.push(
162 input({
163 key: 'mood',
164 value: pane.moodDraft,
165 placeholder: MOOD_PLACEHOLDER,
166 onInput: (value: string) => quietly(io.pane.update(s => ({ ...s, moodDraft: value }))),
167 onSubmit: (value: string) => quietly(submitMood(io, value)),
168 }),
169 )
170 }
171 // A catalog that did not parse is the notice until it is fixed. Orange only on a
172 // surface that cannot play (see noticeColor); elsewhere the text keeps its colour.
173 const notice = catalog.error !== null ? `config/sources.yml: ${catalog.error}` : pane.notice
174 children.push(
175 <Text color={noticeColor(session.surface, notice)} dimColor={notice === null}>
176 {notice ?? HINT}
177 </Text>,
178 )
179 return <Box flexDirection="column">{children}</Box>
180}
181src/state.ts 99 lines1// The mod's state: the initial values of its six atoms, and a reader for all of
2// them. The atoms themselves are declared in hooks/register.tsx, where the
3// engine's state library can see their literal references. Drawing reads
4// them; only handlers, timers and the controller write.
5
6import type { Anim, Catalog, PaneState, Player, Session, Tools } from '../types'
7import type { Io } from './io'
8
9// The pane's id. A pane id is not the plugin's name, so it keeps this form.
10export const PANE_ID = 'claude-music'
11
12export const INITIAL_PLAYER: Player = {
13 intent: 'stop',
14 status: 'stopped',
15 genre: null,
16 station: null,
17 prevStation: null,
18 startedAt: null,
19 message: null,
20 volume: 30,
21 muted: false,
22}
23
24export const INITIAL_ANIM: Anim = {
25 tick: 0,
26 nowMs: 0,
27 swapStep: 0,
28 swapFrom: null,
29 swapTo: null,
30 platterGenre: null,
31 errorUntilTick: 0,
32}
33
34export const INITIAL_CATALOG: Catalog = { genres: [], error: null }
35
36export const INITIAL_TOOLS: Tools = { ffplay: null, ytdlp: null }
37
38export const INITIAL_PANE: PaneState = { moodDraft: '', notice: null }
39
40// The surfaces that play: the terminal, the desktop app and VS Code. Mobile
41// draws the pane only. A non-interactive run (claude -p, the SDK) plays nothing
42// whatever its surface, so the session start checks both.
43export function surfacePlays(surface: string | null): boolean {
44 return surface === 'terminal' || surface === 'desktop' || surface === 'vscode'
45}
46
47const MOBILE_NOTICE = 'Playback is off on mobile. Play from the terminal, the desktop app or VS Code.'
48const RUN_NOTICE = 'Playback is off in this run. It needs an interactive session: the terminal, the desktop app or VS Code.'
49const STALE_NOTICE = 'Claude Music was updated after this session began. Start a new session to play.'
50
51// The notice that says why nothing plays in this session, in words true for it.
52// A session an older build started has no boolean canPlay (a hot reload keeps the
53// state but not the start), so it gets the update notice, not a surface notice.
54export function noPlayText(session: { canPlay: unknown; surface: string | null }): string {
55 if (typeof session.canPlay !== 'boolean') return STALE_NOTICE
56 return session.surface === 'mobile' ? MOBILE_NOTICE : RUN_NOTICE
57}
58
59// The notice line is orange only on a surface that cannot play (mobile, where the
60// pane is all there is). On a surface that plays, a playback problem (a stop that
61// failed, a stream that ended) and the words of a non-interactive run keep the
62// text colour, so orange never marks a music that is playing.
63export function noticeColor(surface: string | null, notice: string | null): 'warning' | undefined {
64 return notice !== null && !surfacePlays(surface) ? 'warning' : undefined
65}
66
67export const INITIAL_SESSION: Session = {
68 canPlay: false,
69 surface: null,
70 focusEndsAt: null,
71 focusMinutes: 25,
72 sessionMinutes: 0,
73 sessionCounted: false,
74 owner: null,
75 totalMinutes: 0,
76 totalSessions: 0,
77 glyphSet: 'unicode',
78}
79
80export type Snapshot = {
81 player: Player
82 anim: Anim
83 catalog: Catalog
84 tools: Tools
85 pane: PaneState
86 session: Session
87}
88
89export async function readSnapshot(io: Io): Promise<Snapshot> {
90 return {
91 player: await io.player.read(),
92 anim: await io.anim.read(),
93 catalog: await io.catalog.read(),
94 tools: await io.tools.read(),
95 pane: await io.pane.read(),
96 session: await io.session.read(),
97 }
98}
99src/catalog.ts 239 lines1// Pure helpers over the station catalog. Nothing here touches the engine:
2// callers pass the catalog and any random numbers in. The catalog is read from
3// config/sources.yml at session start (parseSources); the built-in copy in
4// catalog-data.ts is used only when the file cannot be read.
5
6import type { Catalog, CatalogGenre, CatalogStation, Station } from '../types'
7import { CATALOG_DATA, FALLBACK_GENRES, GENRE_ALIASES } from './catalog-data'
8
9export const UNKNOWN_TONE = 'subtle'
10
11// A station is a YouTube page when its URL names youtube.com/watch, youtu.be/
12// or youtube.com/live/. Everything else is a direct stream.
13export function kindOf(url: string): 'direct' | 'youtube' {
14 return /youtube\.com\/watch|youtu\.be\/|youtube\.com\/live\//i.test(url) ? 'youtube' : 'direct'
15}
16
17// The theme-free tone of a genre (raw hex), or subtle for a genre not in the
18// palette. sources.yml has no tones; the palette lives with the built-in copy.
19export function toneFor(genre: string): string {
20 const found = CATALOG_DATA.find(g => g.name === genre)
21 return found ? found.tone : UNKNOWN_TONE
22}
23
24export function initialCatalog(): Catalog {
25 const genres: CatalogGenre[] = CATALOG_DATA.map(g => ({
26 name: g.name,
27 tone: g.tone,
28 stations: g.stations.map(s => ({ name: s.name, url: s.url, kind: s.kind })),
29 }))
30 return { genres, error: null }
31}
32
33export type ParsedSources = { genres: CatalogGenre[]; error: string | null }
34
35function unquote(value: string): string {
36 const v = value.trim()
37 if (v.length >= 2) {
38 const first = v[0]
39 const last = v[v.length - 1]
40 if ((first === '"' || first === "'") && first === last) return v.slice(1, -1)
41 }
42 return v
43}
44
45// A station being read: its name and line, and its URL once a url line comes.
46// Plain fields, so a station's URL is set on the one place that reads it.
47type Draft = { hasName: boolean; name: string; line: number; url: string }
48
49// Adds the station being read to its genre. It needs a URL; the reason is
50// returned when it has none.
51function addDraft(genre: CatalogGenre, draft: Draft): string | null {
52 if (draft.url === '') return `line ${draft.line}: station "${draft.name}" has no url`
53 const station: CatalogStation = { name: draft.name, url: draft.url, kind: kindOf(draft.url) }
54 genre.stations.push(station)
55 return null
56}
57
58// Reads config/sources.yml with a line parser, in the file's own shape:
59// genre: (indent 0)
60// stations: (indent 2, ignored beyond its presence)
61// - name: ... (indent 4 starts a station)
62// url: ... (indent 6; description and tags are skipped)
63// Lines starting with # and blank lines are skipped. A parse failure, or a file
64// with no genres, returns an error and no genres: nothing plays from it.
65export function parseSources(text: string): ParsedSources {
66 const genres: CatalogGenre[] = []
67 let genre: CatalogGenre | null = null
68 let draft: Draft = { hasName: false, name: '', line: 0, url: '' }
69 const lines = text.split(/\r?\n/)
70
71 for (let i = 0; i < lines.length; i++) {
72 const raw = lines[i] ?? ''
73 const trimmed = raw.trim()
74 if (trimmed === '' || trimmed.startsWith('#')) continue
75 const n = i + 1
76 const indent = raw.length - raw.trimStart().length
77
78 if (indent === 0) {
79 if (genre !== null && draft.hasName) {
80 const why = addDraft(genre, draft)
81 if (why !== null) return { genres: [], error: why }
82 }
83 draft = { hasName: false, name: '', line: 0, url: '' }
84 const match = /^([A-Za-z][\w-]*):$/.exec(trimmed)
85 if (match === null) return { genres: [], error: `line ${n}: expected a genre name ending in a colon` }
86 const name = match[1] ?? ''
87 genre = { name, tone: toneFor(name), stations: [] }
88 genres.push(genre)
89 continue
90 }
91 if (genre === null) return { genres: [], error: `line ${n}: a line before the first genre` }
92 if (indent === 2) {
93 if (trimmed !== 'stations:') return { genres: [], error: `line ${n}: expected "stations:"` }
94 continue
95 }
96 if (indent === 4) {
97 if (draft.hasName) {
98 const why = addDraft(genre, draft)
99 if (why !== null) return { genres: [], error: why }
100 }
101 const match = /^-\s+name:\s*(.*)$/.exec(trimmed)
102 if (match === null) return { genres: [], error: `line ${n}: expected "- name:"` }
103 draft = { hasName: true, name: unquote(match[1] ?? ''), line: n, url: '' }
104 continue
105 }
106 if (indent >= 6) {
107 if (!draft.hasName) return { genres: [], error: `line ${n}: a field outside a station` }
108 const match = /^([a-z_]+):\s*(.*)$/.exec(trimmed)
109 if (match === null) return { genres: [], error: `line ${n}: expected "key: value"` }
110 if (match[1] === 'url') draft.url = unquote(match[2] ?? '')
111 continue
112 }
113 return { genres: [], error: `line ${n}: unexpected indentation` }
114 }
115 if (genre !== null && draft.hasName) {
116 const why = addDraft(genre, draft)
117 if (why !== null) return { genres: [], error: why }
118 }
119 if (genres.length === 0) return { genres: [], error: 'no genres found' }
120 return { genres, error: null }
121}
122
123export function stationKey(genre: string, index: number): string {
124 return `${genre}/${index}`
125}
126
127// The playable stations of one genre, keys and genre filled in.
128export function genreStations(catalog: Catalog, genre: string): Station[] {
129 const found = catalog.genres.find(g => g.name === genre)
130 if (!found) return []
131 return found.stations.map((s, index) => ({
132 key: stationKey(found.name, index),
133 name: s.name,
134 url: s.url,
135 kind: s.kind,
136 genre: found.name,
137 }))
138}
139
140export function allStations(catalog: Catalog): Station[] {
141 return catalog.genres.flatMap(g => genreStations(catalog, g.name))
142}
143
144export function stationByKey(catalog: Catalog, key: string): Station | null {
145 return allStations(catalog).find(s => s.key === key) ?? null
146}
147
148export function toneOf(catalog: Catalog, genre: string | null): string {
149 if (genre === null) return UNKNOWN_TONE
150 const found = catalog.genres.find(g => g.name === genre)
151 return found ? found.tone : UNKNOWN_TONE
152}
153
154export function fallbackStation(genre: string): Station | null {
155 if (!FALLBACK_GENRES.includes(genre)) return null
156 return {
157 key: `${genre}/fallback`,
158 name: `${genre} loop`,
159 url: `assets/${genre}_fallback.mp3`,
160 kind: 'file',
161 genre,
162 }
163}
164
165export function resolveAlias(word: string): string {
166 const lower = word.trim().toLowerCase()
167 return GENRE_ALIASES[lower] ?? lower
168}
169
170// Genre match first (case-insensitive, aliases applied).
171export function findGenre(catalog: Catalog, query: string): string | null {
172 const wanted = resolveAlias(query)
173 if (!wanted) return null
174 const found = catalog.genres.find(g => g.name === wanted)
175 return found ? found.name : null
176}
177
178// Station match, ranked: exact name, then name ending with the query, then a
179// substring; ties keep the catalog (YAML) order. Case-insensitive.
180export function findStation(catalog: Catalog, query: string): Station | null {
181 const q = query.trim().toLowerCase()
182 if (!q) return null
183 let best: { rank: number; station: Station } | null = null
184 for (const station of allStations(catalog)) {
185 const name = station.name.toLowerCase()
186 let rank = -1
187 if (name === q) rank = 0
188 else if (name.endsWith(q)) rank = 1
189 else if (name.includes(q)) rank = 2
190 if (rank < 0) continue
191 if (best === null || rank < best.rank) best = { rank, station }
192 }
193 return best ? best.station : null
194}
195
196function indexFor(rand: number, length: number): number {
197 const i = Math.floor(rand * length)
198 return Math.min(Math.max(i, 0), length - 1)
199}
200
201// A random station of one genre. Stations in `bad` (keys) are skipped, a
202// YouTube station is skipped when yt-dlp is known to be missing, and
203// `exclude` (the current station) is avoided unless it is the only one.
204export function pickStation(
205 catalog: Catalog,
206 genre: string,
207 options: { exclude: string | null; bad: readonly string[]; ytdlpOk: boolean | null; rand: number },
208): Station | null {
209 const usable = genreStations(catalog, genre).filter(
210 s => (s.kind !== 'youtube' || options.ytdlpOk !== false) && !options.bad.includes(s.key),
211 )
212 const others = usable.filter(s => s.key !== options.exclude)
213 if (others.length > 0) return others[indexFor(options.rand, others.length)] ?? null
214 const same = usable.filter(s => s.key === options.exclude)
215 if (same.length > 0) return same[0] ?? null
216 return null
217}
218
219// A genre drawn uniformly from the catalog (shuffle).
220export function pickUniformGenre(catalog: Catalog, rand: number): string | null {
221 if (catalog.genres.length === 0) return null
222 return catalog.genres[indexFor(rand, catalog.genres.length)]?.name ?? null
223}
224
225// A genre weighted by its station count, never the last one unless it is the
226// only genre (vibe).
227export function pickVibeGenre(catalog: Catalog, lastGenre: string | null, rand: number): string | null {
228 let pool = catalog.genres.filter(g => g.stations.length > 0 && g.name !== lastGenre)
229 if (pool.length === 0) pool = catalog.genres.filter(g => g.stations.length > 0)
230 const total = pool.reduce((sum, g) => sum + g.stations.length, 0)
231 if (total === 0) return null
232 let target = rand * total
233 for (const g of pool) {
234 target -= g.stations.length
235 if (target < 0) return g.name
236 }
237 return pool[pool.length - 1]?.name ?? null
238}
239src/clawd.tsx 175 lines1// Clawd, the deck and the crate: Text only, three rows each, the same drawing
2// in the band and in the pane header. Chrome uses the claude theme key. The
3// eyes use the text key, the theme's foreground: it contrasts with the pane in
4// both themes (black on light, white on dark). The genre tones are raw hex from
5// the catalog.
6
7import type { BoxProps, ElementConstructor, TextProps, ThemeKey } from 'claude-code'
8import type { Player } from '../types'
9import type { Disc, Pose } from './clawd-frames'
10import { volumeCells } from './clawd-frames'
11
12export type Table = {
13 Box: ElementConstructor<BoxProps>
14 Text: ElementConstructor<TextProps>
15}
16
17// 'vol ██████░░░░ 60': filled cells in claude, empty ones in subtle; muted is
18// an empty bar.
19export function volumeBar(T: Table, player: Player) {
20 const { Box, Text } = T
21 const filled = volumeCells(player)
22 return (
23 <Box flexDirection="row">
24 <Text dimColor>{'vol '}</Text>
25 <Text color="claude">{'█'.repeat(filled)}</Text>
26 <Text color="subtle">{'░'.repeat(10 - filled)}</Text>
27 <Text>{` ${player.volume}`}</Text>
28 </Box>
29 )
30}
31
32// Clawd, the Claude Code mascot, drawn from half blocks (▀ ▄ █) on an 18 by 6
33// pixel grid, so it is 18 cells wide and 3 rows tall and each pixel is square:
34//
35// y0 ...XXXXXXXXXXXX... head
36// y1 ...XX.XXXXXX.XX... eyes are the two gaps (x5, x12)
37// y2 .XXXXXXXXXXXXXXXX. arms out
38// y3 ...XXXXXXXXXXXX... body
39// y4 ....X.X....X.X.... four legs, which step between frames A and B
40//
41// Half blocks, not quadrant glyphs: quadrants drew with gaps in the desktop app.
42export const CLAWD_COLUMNS = 18
43
44const EYE_X = [5, 12]
45const LEG_X: Record<Pose['legs'], number[]> = { A: [4, 6, 11, 13], B: [5, 7, 10, 12] }
46
47// Whether pixel (x, y) is clay. Open eyes are holes in y1; closed eyes fill them.
48function clay(x: number, y: number, legs: Pose['legs'], eyesOpen: boolean): boolean {
49 if (y === 0 || y === 3) return x >= 3 && x <= 14
50 if (y === 1) return x >= 3 && x <= 14 && (!EYE_X.includes(x) || !eyesOpen)
51 if (y === 2) return x >= 1 && x <= 16
52 if (y === 4) return LEG_X[legs].includes(x)
53 return false
54}
55
56const EYE_MARK: Partial<Record<Pose['eyes'], string>> = { squint: '^', blink: '-', asleep: '-', dead: '×' }
57
58// One text row: two pixel rows per cell, as runs of the same glyph.
59function clawdRow(T: Table, pose: Pose, row: number) {
60 const { Box, Text } = T
61 const eyesOpen = pose.eyes === 'open'
62 const top = row * 2
63 const out: ReturnType<typeof Text>[] = []
64 let run = ''
65 let glyph = ' '
66 const flush = (key: string) => {
67 if (run === '') return
68 out.push(
69 glyph === ' ' ? (
70 <Text key={key}>{run}</Text>
71 ) : (
72 <Text key={key} color="claude">
73 {run}
74 </Text>
75 ),
76 )
77 run = ''
78 }
79 for (let x = 0; x < CLAWD_COLUMNS; x++) {
80 const mark = row === 0 && EYE_X.includes(x) ? EYE_MARK[pose.eyes] : undefined
81 if (mark !== undefined) {
82 flush(`r${x}`)
83 out.push(
84 <Text key={`m${x}`} backgroundColor="claude" color={pose.eyes === 'dead' ? 'error' : 'inverseText'} bold>
85 {mark}
86 </Text>,
87 )
88 glyph = ' '
89 continue
90 }
91 const t = clay(x, top, pose.legs, eyesOpen)
92 const b = clay(x, top + 1, pose.legs, eyesOpen)
93 const g = t && b ? '█' : t ? '▀' : b ? '▄' : ' '
94 if (g !== glyph) flush(`r${x}`)
95 glyph = g
96 run += g
97 }
98 flush('rend')
99 return (
100 <Box flexDirection="row" width={CLAWD_COLUMNS}>
101 {out}
102 </Box>
103 )
104}
105
106export const EYE_COLOR: ThemeKey = 'inverseText'
107
108export function clawdRows(T: Table, pose: Pose) {
109 return [clawdRow(T, pose, 0), clawdRow(T, pose, 1), clawdRow(T, pose, 2)]
110}
111
112function discCell(T: Table, disc: Disc | null, width: number) {
113 const { Box, Text } = T
114 if (disc === null) {
115 return (
116 <Box width={width}>
117 <Text>{' '.repeat(width)}</Text>
118 </Box>
119 )
120 }
121 return (
122 <Box flexDirection="row" width={width}>
123 <Text>{' '}</Text>
124 <Text color={disc.tone} dimColor={disc.dim}>
125 {disc.glyph}
126 </Text>
127 <Text>{' '}</Text>
128 </Box>
129 )
130}
131
132// The deck, seven cells wide: the lifted record (or a z bubble) above the
133// platter record, and the plinth below.
134export function deckRows(T: Table, pose: Pose) {
135 const { Box, Text } = T
136 const top = pose.lift !== null ? discCell(T, pose.lift, 7) : pose.zzz !== null ? (
137 <Box width={7}>
138 <Text dimColor>{` ${pose.zzz} `}</Text>
139 </Box>
140 ) : discCell(T, null, 7)
141 const plinthColor = pose.plinth === 'error' ? 'error' : 'subtle'
142 return [
143 top,
144 discCell(T, pose.disc, 7),
145 <Box width={7}>
146 <Text color={plinthColor} bold={pose.plinth === 'bold'}>
147 {' ▔▔▔▔▔ '}
148 </Text>
149 </Box>,
150 ]
151}
152
153// The crate, one cell wide, at the middle row; it takes the tone it reaches for.
154export function crateColumn(T: Table, pose: Pose) {
155 const { Box, Text } = T
156 return (
157 <Box flexDirection="column" width={1}>
158 <Text>{' '}</Text>
159 <Text color={pose.crateTone}>{'▤'}</Text>
160 <Text>{' '}</Text>
161 </Box>
162 )
163}
164
165export function gapColumn(T: Table) {
166 const { Box, Text } = T
167 return (
168 <Box flexDirection="column" width={1}>
169 <Text>{' '}</Text>
170 <Text>{' '}</Text>
171 <Text>{' '}</Text>
172 </Box>
173 )
174}
175src/clawd-frames.ts 260 lines1// Clawd's pose, as a pure function of the player, the animation clock and the
2// tick. Priority: swap (steps 1 to 8), error, tuning (loading), playing,
3// paused, idle. The render draws what this returns; nothing here draws.
4
5import type { Anim, Player } from '../types'
6
7export type Glyphs = 'unicode' | 'ascii'
8
9export type Eyes = 'open' | 'squint' | 'blink' | 'dead' | 'asleep'
10
11export type Plinth = 'subtle' | 'bold' | 'error'
12
13export type Disc = { glyph: string; tone: string; dim: boolean }
14
15export type PoseName =
16 | 'LIFT'
17 | 'REACH'
18 | 'SWAP'
19 | 'DROP'
20 | 'SETTLE'
21 | 'START'
22 | 'CUE'
23 | 'ERROR'
24 | 'LISTEN'
25 | 'GROOVE'
26 | 'PAUSED'
27 | 'IDLE'
28
29export type Pose = {
30 name: PoseName
31 eyes: Eyes
32 legs: 'A' | 'B'
33 lift: Disc | null
34 disc: Disc | null
35 crateTone: string
36 plinth: Plinth
37 zzz: string | null
38}
39
40export type PoseInput = {
41 player: Player
42 anim: Anim
43 tick: number
44 glyphSet: Glyphs
45 toneOf: (genre: string | null) => string
46}
47
48export const SUBTLE = 'subtle'
49
50const SPIN: Record<Glyphs, readonly string[]> = {
51 unicode: ['◐', '◓', '◑', '◒'],
52 ascii: ['|', '/', '-', '\\'],
53}
54
55const TUNING: Record<Glyphs, string> = { unicode: '◌', ascii: '.' }
56
57const PARKED: Record<Glyphs, string> = { unicode: '◯', ascii: 'o' }
58
59const ZZZ = ['z', 'zz', 'zzz']
60
61export function spinGlyph(glyphSet: Glyphs, n: number): string {
62 const set = SPIN[glyphSet]
63 return set[((n % set.length) + set.length) % set.length] ?? ''
64}
65
66export function tuningGlyph(glyphSet: Glyphs): string {
67 return TUNING[glyphSet]
68}
69
70export function parkedGlyph(glyphSet: Glyphs): string {
71 return PARKED[glyphSet]
72}
73
74function swapPose(input: PoseInput): Pose {
75 const { anim, toneOf } = input
76 const oldTone = toneOf(anim.swapFrom)
77 const newTone = toneOf(anim.swapTo)
78 const hasOld = anim.swapFrom !== null
79 const oldLift: Disc | null = hasOld ? { glyph: '◐', tone: oldTone, dim: false } : null
80 const step = anim.swapStep
81 if (step === 1) {
82 return { name: 'LIFT', eyes: 'open', legs: 'A', lift: oldLift, disc: null, crateTone: SUBTLE, plinth: 'subtle', zzz: null }
83 }
84 if (step === 2) {
85 return { name: 'REACH', eyes: 'squint', legs: 'A', lift: oldLift, disc: null, crateTone: newTone, plinth: 'subtle', zzz: null }
86 }
87 if (step === 3) {
88 return {
89 name: 'SWAP',
90 eyes: 'open',
91 legs: 'A',
92 lift: { glyph: '◑', tone: newTone, dim: false },
93 disc: null,
94 crateTone: oldTone,
95 plinth: 'subtle',
96 zzz: null,
97 }
98 }
99 if (step === 4) {
100 return {
101 name: 'DROP',
102 eyes: 'open',
103 legs: 'A',
104 lift: null,
105 disc: { glyph: '◐', tone: newTone, dim: false },
106 crateTone: newTone,
107 plinth: 'subtle',
108 zzz: null,
109 }
110 }
111 if (step === 5) {
112 return {
113 name: 'SETTLE',
114 eyes: 'open',
115 legs: 'A',
116 lift: null,
117 disc: { glyph: '◐', tone: newTone, dim: false },
118 crateTone: newTone,
119 plinth: 'bold',
120 zzz: null,
121 }
122 }
123 return {
124 name: 'START',
125 eyes: 'open',
126 legs: 'A',
127 lift: null,
128 disc: { glyph: '◐', tone: newTone, dim: false },
129 crateTone: newTone,
130 plinth: 'subtle',
131 zzz: null,
132 }
133}
134
135function cuePose(input: PoseInput): Pose {
136 const { anim, tick, glyphSet, toneOf } = input
137 const tone = toneOf(anim.platterGenre)
138 return {
139 name: 'CUE',
140 eyes: anim.swapStep === 7 ? 'squint' : 'open',
141 legs: 'A',
142 lift: null,
143 disc: { glyph: spinGlyph(glyphSet, tick), tone, dim: false },
144 crateTone: SUBTLE,
145 plinth: 'subtle',
146 zzz: null,
147 }
148}
149
150export function pose(input: PoseInput): Pose {
151 const { player, anim, tick, glyphSet, toneOf } = input
152 if (anim.swapStep >= 1 && anim.swapStep <= 6) return swapPose(input)
153 if (anim.swapStep === 7 || anim.swapStep === 8) return cuePose(input)
154
155 const platterTone = toneOf(anim.platterGenre)
156
157 if (player.status === 'error' && tick < anim.errorUntilTick) {
158 return {
159 name: 'ERROR',
160 eyes: 'dead',
161 legs: 'A',
162 lift: null,
163 disc: anim.platterGenre !== null ? { glyph: parkedGlyph(glyphSet), tone: SUBTLE, dim: false } : null,
164 crateTone: SUBTLE,
165 plinth: 'error',
166 zzz: null,
167 }
168 }
169
170 if (player.status === 'loading') {
171 return {
172 name: 'LISTEN',
173 eyes: tick % 8 === 7 ? 'squint' : 'open',
174 legs: 'A',
175 lift: null,
176 disc: { glyph: tuningGlyph(glyphSet), tone: platterTone, dim: tick % 2 === 0 },
177 crateTone: SUBTLE,
178 plinth: 'subtle',
179 zzz: null,
180 }
181 }
182
183 if (player.status === 'playing') {
184 const turn = player.muted ? Math.floor(tick / 2) : tick
185 return {
186 name: 'GROOVE',
187 eyes: tick % 25 === 24 ? 'blink' : 'open',
188 legs: Math.floor(tick / 5) % 2 === 0 ? 'A' : 'B',
189 lift: null,
190 disc: anim.platterGenre !== null ? { glyph: spinGlyph(glyphSet, turn), tone: platterTone, dim: false } : null,
191 crateTone: SUBTLE,
192 plinth: 'subtle',
193 zzz: null,
194 }
195 }
196
197 if (player.status === 'paused') {
198 return {
199 name: 'PAUSED',
200 eyes: 'asleep',
201 legs: 'A',
202 lift: null,
203 disc: anim.platterGenre !== null ? { glyph: '◐', tone: platterTone, dim: true } : null,
204 crateTone: SUBTLE,
205 plinth: 'subtle',
206 zzz: ZZZ[Math.floor(tick / 5) % ZZZ.length] ?? null,
207 }
208 }
209
210 return {
211 name: 'IDLE',
212 eyes: tick % 26 === 25 ? 'blink' : 'open',
213 legs: 'A',
214 lift: null,
215 disc: anim.platterGenre !== null ? { glyph: parkedGlyph(glyphSet), tone: SUBTLE, dim: false } : null,
216 crateTone: SUBTLE,
217 plinth: 'subtle',
218 zzz: null,
219 }
220}
221
222// The text after the deck: what the player is doing, in words.
223export function statusWord(player: Player, anim: Anim, tick: number): string {
224 if (player.status === 'error' && tick < anim.errorUntilTick) return player.message ?? 'error'
225 if (player.status === 'loading') return `tuning in${'.'.repeat((tick % 3) + 1)}`
226 if (player.status === 'playing') return player.muted ? 'muted' : 'playing'
227 if (player.status === 'paused') return 'paused'
228 if (player.status === 'error') return player.message ?? 'error'
229 return 'stopped · /music play'
230}
231
232// The pane's status badge: a glyph and a word.
233export function badgeOf(player: Player, anim: Anim, tick: number): { glyph: string; word: string } {
234 if (player.status === 'playing') return { glyph: '●', word: player.muted ? 'muted' : 'playing' }
235 if (player.status === 'paused') return { glyph: '❚❚', word: 'paused' }
236 if (player.status === 'loading') return { glyph: '◌', word: 'tuning in' }
237 if (player.status === 'error' && tick < anim.errorUntilTick) return { glyph: '×', word: 'error' }
238 if (player.status === 'error') return { glyph: '■', word: 'stopped' }
239 return { glyph: '■', word: 'stopped' }
240}
241
242// The word of the pause button: pause while playing or tuning, resume when
243// paused, play when stopped.
244export function pauseWord(player: Player): 'pause' | 'resume' | 'play' {
245 if (player.intent === 'play') return 'pause'
246 if (player.status === 'paused' || (player.intent === 'pause' && player.station !== null)) return 'resume'
247 return 'play'
248}
249
250// The pane's pause button label, with its hotkey.
251export function pauseLabel(player: Player): string {
252 return `${pauseWord(player)} p`
253}
254
255// Filled cells of the volume bar (of ten). Muted shows an empty bar.
256export function volumeCells(player: Player): number {
257 if (player.muted) return 0
258 return Math.round(Math.min(100, Math.max(0, player.volume)) / 10)
259}
260src/layout.ts 52 lines1// The band's width plan, pure. The band is Clawd (18 cells and a gap), then an
2// optional deck (7 cells, a gap, the crate, a gap), then the text column. Its
3// buttons are plain (`b: prev`), so each one's width is known up front.
4//
5// Widths: below 44 the band keeps pause only; 44 to 53 shows three buttons, 54 to
6// 69 four, from 70 six; from 80 the deck is drawn too.
7
8export type ButtonSet = 'pause' | 'three' | 'four' | 'six'
9
10export type BandPlan = {
11 showDeck: boolean
12 buttons: ButtonSet
13 // Cells left for the text column, after Clawd, the deck and the gaps.
14 textColumns: number
15}
16
17// Cells each plain button takes in its widest state: `hotkey: label`.
18export const PLAIN_CELLS = {
19 prev: 'b: prev'.length,
20 pause: 'p: resume'.length,
21 next: 'n: next'.length,
22 mute: 'm: unmute'.length,
23 down: 'd: vol-'.length,
24 up: 'u: vol+'.length,
25} as const
26
27export const BUTTON_GAP = 1
28export const ART_COLUMNS = 19 // Clawd (18) and one gap
29export const DECK_COLUMNS = 10 // deck (7), gap, crate (1), gap
30export const DECK_FROM = 80
31export const PAUSE_ONLY_BELOW = 44
32
33export function setCells(set: ButtonSet): number {
34 const names: Array<keyof typeof PLAIN_CELLS> =
35 set === 'pause'
36 ? ['pause']
37 : set === 'three'
38 ? ['prev', 'pause', 'next']
39 : set === 'four'
40 ? ['prev', 'pause', 'next', 'mute']
41 : ['prev', 'pause', 'next', 'mute', 'down', 'up']
42 const cells = names.reduce((sum, name) => sum + PLAIN_CELLS[name], 0)
43 return cells + BUTTON_GAP * (names.length - 1)
44}
45
46export function bandPlan(width: number): BandPlan {
47 const buttons: ButtonSet = width < PAUSE_ONLY_BELOW ? 'pause' : width < 54 ? 'three' : width < 70 ? 'four' : 'six'
48 const showDeck = width >= DECK_FROM
49 const textColumns = Math.max(0, width - ART_COLUMNS - (showDeck ? DECK_COLUMNS : 0))
50 return { showDeck, buttons, textColumns }
51}
52src/moods.ts 120 lines1// Mood keywords for /music mood, the mood input and the mood chips. Matching
2// is substring based over lowercase text, English and Russian. A group that
3// matches adds its genre weights once. Russian inflection breaks substrings
4// (for example 'сон' does not match 'сну'); the keywords list the common forms.
5
6export type MoodGroup = {
7 words: readonly string[]
8 genres: Readonly<Record<string, number>>
9}
10
11export const MOOD_TABLE: readonly MoodGroup[] = [
12 {
13 words: ['focus', 'фокус', 'study', 'studying', 'учеб', 'учи', 'deep work', 'concentrat', 'концентр'],
14 genres: { lofi: 3, ambient: 2, classical: 2, electronic: 1 },
15 },
16 {
17 words: ['work', 'работ', 'code', 'coding', 'код', 'program', 'программ', 'debug', 'отлад'],
18 genres: { lofi: 2, ambient: 2, classical: 1, electronic: 1 },
19 },
20 {
21 words: ['chill', 'relax', 'calm', 'easy', 'спокой', 'расслаб', 'отдых', 'тихо', 'quiet', 'мягк'],
22 genres: { ambient: 3, lofi: 2, lounge: 2, jazz: 1 },
23 },
24 {
25 words: ['rain', 'дожд', 'storm', 'гроз', 'cozy', 'уют'],
26 genres: { lofi: 2, ambient: 2, jazz: 1 },
27 },
28 {
29 words: ['late', 'night', 'ночь', 'ночн', 'поздн', 'midnight', 'полноч'],
30 genres: { synthwave: 3, electronic: 2, lounge: 2, jazz: 1 },
31 },
32 {
33 words: ['sleep', 'сон', 'спат', 'спать', 'drift', 'bed'],
34 genres: { ambient: 3, classical: 2 },
35 },
36 {
37 words: ['energy', 'energ', 'hype', 'workout', 'gym', 'драйв', 'энерг', 'бодр', 'активн', 'power', 'спорт'],
38 genres: { electronic: 3, synthwave: 2, indie: 2 },
39 },
40 {
41 words: ['dance', 'party', 'танц', 'вечерин', 'клуб', 'club'],
42 genres: { electronic: 3, synthwave: 2 },
43 },
44 {
45 words: ['happy', 'fun', 'sunny', 'весел', 'радост', 'счаст', 'солнеч', 'bright', 'upbeat'],
46 genres: { indie: 3, jazz: 1 },
47 },
48 {
49 words: ['sad', 'melanch', 'грус', 'печал', 'тоск'],
50 genres: { classical: 3, ambient: 2 },
51 },
52 {
53 words: ['coffee', 'morning', 'кофе', 'утр', 'breakfast', 'завтрак', 'cafe', 'кафе'],
54 genres: { jazz: 3, lounge: 2, indie: 1 },
55 },
56 {
57 words: ['retro', 'ретро', '80s', 'neon', 'неон', 'synth', 'синт'],
58 genres: { synthwave: 3, electronic: 1 },
59 },
60 {
61 words: ['piano', 'пиан', 'classic', 'класс', 'orchestra', 'оркестр', 'symphon', 'симфон'],
62 genres: { classical: 3 },
63 },
64 {
65 words: ['space', 'космос', 'космич', 'dream', 'мечт', 'drone'],
66 genres: { ambient: 3, electronic: 1 },
67 },
68 {
69 words: ['jazz', 'джаз', 'bossa', 'босса'],
70 genres: { jazz: 3, lounge: 1 },
71 },
72 {
73 words: ['lounge', 'лаунж'],
74 genres: { lounge: 3, jazz: 1 },
75 },
76 {
77 words: ['indie', 'инди', 'folk', 'фолк', 'acoustic', 'акуст', 'guitar', 'гитар'],
78 genres: { indie: 3 },
79 },
80 {
81 words: ['lofi', 'lo-fi', 'лоуфай'],
82 genres: { lofi: 3 },
83 },
84]
85
86// Summed genre scores for a text. Empty object when nothing matches.
87export function scoreMood(text: string): Record<string, number> {
88 const lower = text.trim().toLowerCase()
89 const scores: Record<string, number> = {}
90 if (!lower) return scores
91 for (const group of MOOD_TABLE) {
92 if (!group.words.some(word => lower.includes(word))) continue
93 for (const [genre, weight] of Object.entries(group.genres)) {
94 scores[genre] = (scores[genre] ?? 0) + weight
95 }
96 }
97 return scores
98}
99
100// The top-scoring genre, picked at random among ties and never the last genre
101// when another tie is available. `matched` is false when no keyword matched;
102// the caller then picks a vibe.
103export function pickMoodGenre(
104 text: string,
105 lastGenre: string | null,
106 rand: number,
107 available?: readonly string[],
108): { genre: string | null; matched: boolean } {
109 const scores = scoreMood(text)
110 // With the catalog's genre names, only genres the config still lists can win.
111 const entries = Object.entries(scores).filter(([genre]) => available === undefined || available.includes(genre))
112 if (entries.length === 0) return { genre: null, matched: false }
113 const top = Math.max(...entries.map(([, score]) => score))
114 let candidates = entries.filter(([, score]) => score === top).map(([genre]) => genre)
115 const others = candidates.filter(genre => genre !== lastGenre)
116 if (others.length > 0) candidates = others
117 const index = Math.min(Math.max(Math.floor(rand * candidates.length), 0), candidates.length - 1)
118 return { genre: candidates[index] ?? null, matched: true }
119}
120