SLOPSHOPPER

clawd-music

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

panebandcommandtoaststatusprocess
v0.1.0Apache-2.0updated 2026-10-15Sansay411/Clawd_Music_Claude_Code_Plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-music
│ ┃ Claude Music ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ██▀██████▀██ Claude Music × error │ clawd-music │ │ ┃ ▀▀████████████▀▀ SomaFM - Suburbs of Goa ● clawd-music: config/│ ffplay not found; run: winget install │ │ ┃ ▀ ▀ ▀ ▀ vol ███░░░░░░░ 30 ⏺ Read(src/auth.ts) │ Gyan.FFmpeg │ │ ┃ ○ 1: lofi (2) ○ 2: jazz (4) ○ 3: classical ( ⎿ Read 6 lines ╰────────────────────────────────────────────╯ │ ┃ stations · electronic ⏺ Update(src/auth.ts) ╭─────────────────────────────╮ │ ┃ SomaFM - Beat Blender ⎿ Added 2 lines, removed 1 line │ clawd-music │ │ ┃ SomaFM - The Trip ⏺ Bash(bun test) │ no playable station in lofi │ │ ┃ SomaFM - Cliqhop IDM ⎿ 3 pass, 1 fail ╰─────────────────────────────╯ │ ┃ SomaFM - Dub Step Beyond │ ┃ ▸ SomaFM - Suburbs of Goa ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ SomaFM - Groove Salad Classic │ ┃ b: prev p: play n: next m: mute d: vol- u: v ✻ Worked for 42s · done 4:20 PM │ ┃ mood, e.g. rainy focus │ ┃ Press a genre, or type a mood. Esc closes; › /music │ ┃ the music keeps playing. ⎿ clawd-music: Claude Music opened. │ │ ██▀██████▀██ SomaFM - Suburbs of Goa · electronic ▀▀████████████▀▀ ▤ no playable station in lofi vol ███░░░░░░░ 30 ▀ ▀ ▀ ▀ ▔▔▔▔▔ b: prev p: play n: next m: mute d: vol- u: vol+ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ clawd-music: Claude Music: error: no playable station in lofi

Draws

Band
██▀██████▀██ SomaFM - Suburbs of Goa · electronic ▀▀████████████▀▀ ▤ no playable station in lofi vol ███░░░░░░░ 30 ▀ ▀ ▀ ▀ ▔▔▔▔▔ b: prev p: play n: next m: mute d: vol- u: vol+
Pane · Claude Music
██▀██████▀██ Claude Music × error ▀▀████████████▀▀ SomaFM - Suburbs of Goa ▀ ▀ ▀ ▀ vol ███░░░░░░░ 30 ○ 1: lofi (2) ○ 2: jazz (4) ○ 3: classical (3) ○ 4: ambient stations · electronic SomaFM - Beat Blender SomaFM - The Trip SomaFM - Cliqhop IDM SomaFM - Dub Step Beyond ▸ SomaFM - Suburbs of Goa SomaFM - Groove Salad Classic b: prev p: play n: next m: mute d: vol- u: vol+ s: stop mood, e.g. rainy focus Press a genre, or type a mood. Esc closes; the music keeps playing.
README

Clawd Music

A tiny radio player that lives inside Claude Code. A little pixel mascot spins the record, and swaps it when you change the genre.

License: Apache-2.0 Platform: Windows No skills, no agents No telemetry

   ██▀██████▀██
 ▀▀████████████▀▀   ◐   ○ 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.

Why it is different

  • No skills, no agents, no prompts. It is a Claude Code mod: only UI and one slash command. It cannot read your conversation or your code.
  • No telemetry, no accounts, no keys. See PRIVACY and SECURITY.
  • Eight genres, 30+ stations (SomaFM, WDCB, All Classical Portland, France Musique, Lofi Girl, Chillhop and more), streamed from their own servers. No audio is bundled.
  • Plays in the terminal, the desktop app and VS Code.

Install

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.

Commands

CommandWhat it does
/musicopen the pane
/music play [genre or station]a genre (random station) or a station by name; no argument resumes
/music stop / pausestop (keeps the station) / pause or resume
/music next / prevnext station in the genre / the previous one
`/music vol <0-100\up\down>`volume
/music mutemute or unmute
/music mood <text>pick a genre from mood words (English and Russian): /music mood rainy focus
/music vibe / shufflea random genre
/music focus [minutes]a timer, 1-120 min; the music stops and a chime plays at the end
/music list / status / reset / helplist 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.

Limitations (honest list)

  • Windows only for now. It uses where.exe and taskkill. macOS/Linux support is the best first contribution, see CONTRIBUTING.
  • Stopping the player ends all ffplay.exe on the machine, so another program's ffplay stops too.
  • Live radio has no position, so pause and volume changes restart the stream.
  • The YouTube stations depend on yt-dlp and YouTube's own terms; use them at your own discretion.
  • No local fallback audio ships with the repo; if every station of a genre is down, you get an error message.
  • Early software: it runs on Claude Code's early-access mod API, which may change.

Development

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.

License

Apache-2.0. Station streams belong to their operators.

Source 17 files
hooks/register.tsx 125 lines
1// 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}
125
src/band.tsx 169 lines
1// 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}
169
src/commands.ts 235 lines
1// 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}
235
src/controller.ts 976 lines
1// 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}
976
src/io.ts 62 lines
1// 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}
62
src/pane.tsx 181 lines
1// 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}
181
src/state.ts 99 lines
1// 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}
99
src/catalog.ts 239 lines
1// 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}
239
src/clawd.tsx 175 lines
1// 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}
175
src/clawd-frames.ts 260 lines
1// 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}
260
src/layout.ts 52 lines
1// 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}
52
src/moods.ts 120 lines
1// 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