SLOPSHOPPER

music-mod

/music: what macOS Music.app is playing, in one line above the prompt. A pressable play/pause glyph, track · artist · album, a progress bar with the clocks…

newbandcommandprocesstimer
v0.6.1MITupdated 2026-10-06zyx1121/music-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · music-mod
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /music ⎿ music-mod: Music hidden ⚠️ could not read Music.app Music.app answered something that is not JSON ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⚠️ could not read Music.app Music.app answered something that is not JSON ⟨Claude Code's own drawing⟩
README

music-mod

/music for Claude Code: what Music.app is playing, in one line above the prompt, live, with play/pause and next a click away.

claude-code · mod · function-hooks · macos · music · now-playing

Claude Code plugin  CI  License: MIT

▶️ Tipsy · WANUKA · Greenhorn ████████████░░░░░░░░ 2:43 / 3:39 ⏭️

<sub>The band directly above the prompt, in any layout. Its clock ticks every second while shown; ▶️ and ⏭️ are clickable.</sub>

Long sessions have a soundtrack, and reaching for Music.app to check what is on breaks the flow. This mod keeps the answer one command away, inside the terminal you are already looking at: the track, where it is, and two glyphs to pause it or skip it.

It is a Claude Code mod: a plugin whose behaviour lives in a TypeScript hooks module, written against the same engine API as the built-in /diff pane. It draws into the AbovePrompt band, so it sits above the input whether the transcript is fullscreen or inline, and never takes a side dock. No shell hooks, no MCP server, and one osascript call every five seconds however many sessions are open.

Install

Function hooks are early access, so the engine loads a mod only with the flag on. Put it in your shell profile:

export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

Then install it as a plugin, from GitHub or from a local clone:

/plugin marketplace add zyx1121/music-mod
/plugin install music-mod@music-mod

Or try it for one session without installing:

claude --plugin-dir /path/to/music-mod

The line shows as soon as an interactive session starts. In the session:

/music        hide it, or show it again

Your last choice is remembered between sessions.

macOS only: it reads Music.app through Apple events. The first read may ask you to allow your terminal to control Music.

Reading never launches Music.app, not even when a read lands while you quit it: the events go to the running process by its ID, and a process that has gone reads as closed. Only a click on the state glyph or ⏭️ talks to Music.app by name.

What it shows

One line, left to right:

PartContent
▶️ ⏸️ ⏩ ⏪The player state. Click it to play or pause.
Titletrack · artist · album, cut with an ellipsis when the band is narrow
BarProgress, stretched to fill whatever width the title leaves (6 cells at the least), then position / duration
⏭️Click it to skip to the next track.

When Music.app is closed or stopped the band says so instead. When osascript fails (not authorized, timed out), the band shows the reason and keeps polling. A survey that takes the band is yielded to.

Configuration

FieldTypeDefaultWhat it does
refreshMsnumber5000Milliseconds between reads of Music.app while the band is shown. Every open session shares one read per interval, and the clock ticks in between. Floored at 1000.
showOnStartbooleantrueShow the line as soon as an interactive session starts. Once you have used /music, that choice wins over this.

Set it as any plugin userConfig field: /config, or music-mod.refreshMs in settings.

How it is built

  • hooks/register.ts exports register(on, options). On session.start it registers /music and shows the band (the last /music choice from $.store, else showOnStart); command.run toggles it and remembers; a one-second $.clock.every tick redraws the clock from the last reading and, once refreshMs has passed (or the track has run out), takes a new one; the timer is cancelled on session.end; a pressed glyph runs its AppleScript and reads at once.
  • hooks/now-playing.ts holds the JXA script osascript -l JavaScript runs, the parser that turns its JSON into a Model, the clock and progress-bar formatters, the position carried forward between reads, and the one-line AppleScript behind each glyph. The script asks only for what the band draws: no system volume (reading it wakes coreaudiod) and no playlist walk.
  • hooks/shared-read.ts is the reading every session shares: a small JSON file at $TMPDIR/music-mod/now.json. A session takes the reading there when it is recent enough, else claims the file, runs osascript and writes the result back, so the other sessions wait for it instead of running their own.
  • hooks/views/band-view.tsx draws the Model with the engine's Box, Text and Button on ui.render for the AbovePrompt component.
  • types/claude-code.d.ts is the engine contract this mod is typed against, copied from anthropics/claude-code/mods/types.
bunx -p typescript tsc -p tsconfig.json          # typecheck
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test .   # 35 tests, the engine's own harness

The API these mods are written against may change between releases without notice. When it does, refresh types/claude-code.d.ts from upstream and let the typecheck point at what moved.

Contributing

Issues and PRs are welcome. Ground rules live in CONTRIBUTING.md.

License

MIT · now playing: whatever you left on

Source 4 files
hooks/register.tsx 294 lines
1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { EngineInterface, On, PluginOptions, Timer } from 'claude-code'
5
6import { ARGV, controlArgvOf, hasEnded, modelAt, modelOf, READ_TIMEOUT_MS, type Control, type Model } from './now-playing'
7import { cachePathOf, entryOf, isFresh, type Entry } from './shared-read'
8import { bandView } from './views/band-view'
9
10export const COMMAND_NAME = 'music'
11export const DEFAULT_REFRESH_MS = 5000
12export const MIN_REFRESH_MS = 1000
13
14/** How often the band redraws its clock while playing; no read happens on a tick. */
15export const TICK_MS = 1000
16
17/** At a track's end, a reading this recent (another session's) is still taken. */
18export const END_MAX_AGE_MS = 1000
19
20export const SHOWN_TEXT = 'Music shown above the prompt'
21export const HIDDEN_TEXT = 'Music hidden'
22
23/** The store key under which the last /music choice is kept between sessions. */
24export const STORE_SHOWN_KEY = 'shown'
25
26/**
27 * The refresh interval the options ask for, floored at MIN_REFRESH_MS.
28 *
29 * @param options the plugin's userConfig values
30 * @returns milliseconds between reads
31 */
32export function refreshMsOf(options: PluginOptions): number {
33  const value = options.refreshMs
34
35  return typeof value === 'number' && Number.isFinite(value)
36    ? Math.max(MIN_REFRESH_MS, Math.floor(value))
37    : DEFAULT_REFRESH_MS
38}
39
40type Host = {
41  run: (
42    argv: readonly string[],
43    init: { timeoutMs: number },
44  ) => Promise<{ exitCode: number; stdout: string; stderr: string }>
45  invalidate: () => void
46  every: (ms: number, fn: () => void) => Timer
47  now: () => Promise<number>
48  readShared: () => Promise<Entry | null>
49  writeShared: (entry: Entry) => Promise<void>
50  storeGet: (key: string) => Promise<unknown>
51  storeSet: (key: string, value: unknown) => Promise<void>
52}
53
54/**
55 * Whether the band shows at a session's start: the last /music choice the
56 * store kept, else the `showOnStart` option (true unless set false).
57 *
58 * @param kept what the store holds under STORE_SHOWN_KEY
59 * @param options the plugin's userConfig values
60 * @returns whether to show
61 */
62export function isShownAtStart(kept: unknown, options: PluginOptions): boolean {
63  if (typeof kept === 'boolean') {
64    return kept
65  }
66
67  return options.showOnStart !== false
68}
69
70/**
71 * The engine calls the band needs, taken off `$`. The shared reading lives
72 * under `TMPDIR`; a file the engine cannot read or write counts as absent.
73 *
74 * @param $ the engine
75 * @returns the host
76 */
77async function hostOf($: EngineInterface): Promise<Host> {
78  const path = cachePathOf(await $.env.get('TMPDIR').catch(() => undefined))
79
80  return {
81    run: (argv, init) => $.process.run(argv, init),
82    invalidate: () => $.ui.invalidate('ui.render'),
83    every: (ms, fn) => $.clock.every(ms, fn),
84    now: () => $.clock.now(),
85    readShared: () =>
86      $.fs.read(path).then(
87        text => (typeof text === 'string' ? entryOf(text) : null),
88        () => null,
89      ),
90    writeShared: entry => $.fs.write(path, JSON.stringify(entry)).catch(() => undefined),
91    storeGet: key => $.store.get(key),
92    storeSet: (key, value) => $.store.set(key, value),
93  }
94}
95
96/**
97 * The Music band: one line above the prompt, shown from the session's start
98 * (the last /music choice, else `showOnStart`) and toggled by /music. While
99 * it shows, a one-second tick redraws the clock from the last reading and,
100 * once `refreshMs` has passed, takes a new one: another session's from the
101 * shared file when it is recent enough, else its own from osascript. A press
102 * on a glyph sends its control and reads at once.
103 *
104 * @param on the engine's hook registrar
105 * @param options the plugin's userConfig values
106 */
107export function register(on: On, options: PluginOptions): void {
108  const refreshMs = refreshMsOf(options)
109
110  let model: Model = { kind: 'idle' }
111  let timer: Timer | null = null
112  let isShown = false
113  let isReading = false
114  let checkedAt = 0
115  let host: Host | null = null
116
117  /**
118   * Takes a reading no older than `maxAgeMs`: the shared one when it is,
119   * else a new osascript run, claimed in the shared file first so the other
120   * sessions wait for it instead of running their own.
121   */
122  async function read(host: Host, maxAgeMs: number): Promise<void> {
123    if (isReading) {
124      return
125    }
126
127    isReading = true
128
129    try {
130      const at = await host.now()
131      const shared = await host.readShared()
132
133      if (isFresh(shared, at, maxAgeMs)) {
134        if (!shared.run) {
135          // another session's read is under way: look again on the next tick
136          checkedAt = Number.NEGATIVE_INFINITY
137
138          return
139        }
140
141        checkedAt = at
142
143        if (model.kind !== 'ok' || model.readAt !== shared.readAt) {
144          model = modelOf(shared.run, shared.readAt)
145        }
146
147        return
148      }
149
150      checkedAt = at
151
152      await host.writeShared({ readAt: at, run: null })
153
154      const run = await host.run(ARGV, { timeoutMs: READ_TIMEOUT_MS })
155      const readAt = await host.now()
156
157      model = modelOf(run, readAt)
158      await host.writeShared({ readAt, run })
159    } catch (error) {
160      model = { kind: 'error', text: error instanceof Error ? error.message : String(error) }
161    } finally {
162      isReading = false
163      host.invalidate()
164    }
165  }
166
167  async function tick(host: Host): Promise<void> {
168    const at = await host.now().catch(() => null)
169
170    if (at === null) {
171      return
172    }
173
174    // a reading that already stood at the end (the track outruns its
175    // metadata) waits for the usual refresh instead of re-reading each tick
176    const isEndDue = hasEnded(model, at) && model.kind === 'ok' && !hasEnded(model, model.readAt)
177
178    if (isEndDue) {
179      await read(host, END_MAX_AGE_MS)
180    } else if (at < checkedAt || at - checkedAt >= refreshMs) {
181      await read(host, refreshMs)
182    } else if (model.kind === 'ok' && model.now.state === 'playing') {
183      host.invalidate()
184    }
185  }
186
187  async function control(which: Control): Promise<void> {
188    if (!host) {
189      return
190    }
191
192    const run = await host.run(controlArgvOf(which), { timeoutMs: READ_TIMEOUT_MS }).catch(() => null)
193
194    if (run && run.exitCode !== 0) {
195      model = { kind: 'error', text: `osascript exited ${run.exitCode}: ${run.stderr.trim()}` }
196      host.invalidate()
197
198      return
199    }
200
201    await read(host, 0)
202  }
203
204  const actions = {
205    playpause: () => void control('playpause'),
206    next: () => void control('next'),
207  }
208
209  function stop(): void {
210    timer?.cancel()
211    timer = null
212    isShown = false
213  }
214
215  async function show(engine: Host): Promise<void> {
216    host = engine
217    isShown = true
218    model = { kind: 'idle' }
219    await read(engine, refreshMs)
220
221    timer?.cancel()
222    timer = engine.every(TICK_MS, () => {
223      void tick(engine)
224    })
225  }
226
227  function hide(engine: Host): void {
228    stop()
229    engine.invalidate()
230  }
231
232  on('session.start', async ($, e, next) => {
233    try {
234      await $.command.register({
235        name: COMMAND_NAME,
236        description: 'What Music.app is playing, one line above the prompt',
237      })
238    } catch (error) {
239      $.ui.log(
240        `/${COMMAND_NAME} did not register: ${error instanceof Error ? error.message : String(error)}`,
241      )
242    }
243
244    if (e.isInteractive && e.surface === 'terminal') {
245      const engine = await hostOf($)
246      const kept = await engine.storeGet(STORE_SHOWN_KEY).catch(() => undefined)
247
248      if (isShownAtStart(kept, options)) {
249        await show(engine)
250      }
251    }
252
253    return next(e)
254  })
255
256  on('command.run', { command: COMMAND_NAME }, async $ => {
257    const engine = await hostOf($)
258    const isHiding = isShown
259
260    if (isHiding) {
261      hide(engine)
262    } else {
263      await show(engine)
264    }
265
266    await engine.storeSet(STORE_SHOWN_KEY, !isHiding).catch(() => undefined)
267
268    return { text: isHiding ? HIDDEN_TEXT : SHOWN_TEXT }
269  })
270
271  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
272    if (!isShown || e.props.hasSurvey) {
273      return next(e)
274    }
275
276    const { Box, Text, Button } = $.ui.resolve(e)
277    const at = await $.clock.now()
278    const beneath = await next(e)
279
280    return (
281      <Box flexDirection="column">
282        {bandView({ Box, Text, Button }, modelAt(model, at), e.props.bodyColumns, actions)}
283        {beneath}
284      </Box>
285    )
286  })
287
288  on('session.end', ($, e, next) => {
289    stop()
290
291    return next(e)
292  })
293}
294
hooks/now-playing.ts 236 lines
1/**
2 * What Music.app reports, read with one `osascript -l JavaScript` run and
3 * parsed from the JSON it prints: only what the band draws.
4 */
5export type NowPlaying = {
6  state: 'closed' | 'stopped' | 'playing' | 'paused' | 'fast forwarding' | 'rewinding'
7  track: { name: string; artist: string; album: string; duration: number } | null
8  position: number | null
9}
10
11/**
12 * The pane's state: nothing read yet, a reading taken at `readAt`
13 * (milliseconds since the epoch), or why the read failed.
14 */
15export type Model =
16  | { kind: 'idle' }
17  | { kind: 'ok'; now: NowPlaying; readAt: number }
18  | { kind: 'error'; text: string }
19
20/**
21 * The JXA script osascript runs. One IIFE whose value is the JSON text
22 * (osascript prints the top-level expression's value). It asks Music.app for
23 * the fewest properties the band draws: no system volume (each read of it
24 * wakes coreaudiod) and no playlist walk (`tracks.length` is slow on a
25 * long playlist).
26 *
27 * It never launches Music.app. `Application('Music')` relaunches the app
28 * when a read lands while it quits, so the script finds the running process
29 * through NSRunningApplication (no Apple event) and sends each get as a raw
30 * Apple event to that process ID. An event to a process that has gone fails
31 * (-600, -609) and the read reports closed.
32 */
33export const SCRIPT = `(() => {
34  ObjC.import('AppKit')
35  const D = $.NSAppleEventDescriptor
36  const code = s => ((s.charCodeAt(0) << 24) | (s.charCodeAt(1) << 16) | (s.charCodeAt(2) << 8) | s.charCodeAt(3)) >>> 0
37  const text = n => String.fromCharCode((n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255)
38  const STATES = { kPSS: 'stopped', kPSP: 'playing', kPSp: 'paused', kPSF: 'fast forwarding', kPSR: 'rewinding' }
39  const GONE = [-600, -609]
40  const WHY = { '-1712': 'Music.app did not answer in time', '-1743': 'not allowed to send Apple events to Music.app' }
41  const SEND = 0x3 | 0x10 | 0x80 // wait for the reply, never interact, never reconnect
42  const DEADLINE = Date.now() + 4000 // the whole read, a second inside READ_TIMEOUT_MS
43  const MISSING = code('msng') // missing value: a typeType descriptor holding 'msng'
44  const out = { state: 'closed', track: null, position: null }
45  const found = $.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.apple.Music').js
46  if (found.length === 0) return JSON.stringify(out)
47  const target = D.descriptorWithProcessIdentifier(found[0].processIdentifier)
48  const property = (id, of) => {
49    const spec = D.recordDescriptor
50    spec.setDescriptorForKeyword(D.descriptorWithTypeCode(code('prop')), code('want'))
51    spec.setDescriptorForKeyword(D.descriptorWithEnumCode(code('prop')), code('form'))
52    spec.setDescriptorForKeyword(D.descriptorWithTypeCode(code(id)), code('seld'))
53    spec.setDescriptorForKeyword(of || D.nullDescriptor, code('from'))
54    return spec.coerceToDescriptorType(code('obj '))
55  }
56  const get = spec => {
57    const event = D.appleEventWithEventClassEventIDTargetDescriptorReturnIDTransactionID(code('core'), code('getd'), target, -1, 0)
58    event.setParamDescriptorForKeyword(spec, code('----'))
59    const error = $()
60    const reply = event.sendEventWithOptionsTimeoutError(SEND, Math.max(0.1, (DEADLINE - Date.now()) / 1000), error)
61    if (reply.isNil()) throw { errorNumber: Number(error.code) }
62    const errn = reply.paramDescriptorForKeyword(code('errn'))
63    if (!errn.isNil() && errn.int32Value !== 0) throw { errorNumber: errn.int32Value }
64    return reply.paramDescriptorForKeyword(code('----'))
65  }
66  try {
67    const state = text(get(property('pPlS')).enumCodeValue)
68    out.state = STATES[state] || state
69    if (out.state === 'stopped') return JSON.stringify(out)
70    const track = property('pTrk')
71    const string = id => ObjC.unwrap(get(property(id, track)).stringValue)
72    const number = d => (d.descriptorType === code('type') && d.typeCodeValue === MISSING ? null : d.doubleValue)
73    out.track = { name: string('pnam'), artist: string('pArt'), album: string('pAlb'), duration: number(get(property('pDur', track))) }
74    try { out.position = number(get(property('pPos'))) } catch (e) { out.position = null }
75    return JSON.stringify(out)
76  } catch (e) {
77    if (e instanceof Error) throw e
78    if (GONE.includes(e.errorNumber)) return JSON.stringify({ state: 'closed', track: null, position: null })
79    throw new Error((WHY[e.errorNumber] || 'Music.app answered error') + ' (' + e.errorNumber + ')')
80  }
81})()`
82
83/** The argv that reads Music.app. */
84export const ARGV: readonly string[] = ['osascript', '-l', 'JavaScript', '-e', SCRIPT]
85
86/** How long one read may take before it counts as failed. */
87export const READ_TIMEOUT_MS = 5000
88
89const STATES = new Set(['closed', 'stopped', 'playing', 'paused', 'fast forwarding', 'rewinding'])
90
91function isRecord(value: unknown): value is Record<string, unknown> {
92  return typeof value === 'object' && value !== null && !Array.isArray(value)
93}
94
95function numberOr(value: unknown, fallback: number): number {
96  return typeof value === 'number' && Number.isFinite(value) ? value : fallback
97}
98
99function stringOr(value: unknown, fallback: string): string {
100  return typeof value === 'string' ? value : fallback
101}
102
103/**
104 * The reading a finished osascript run stands for.
105 *
106 * @param run the process result
107 * @param readAt when the run was taken, milliseconds since the epoch
108 * @returns the reading, or the error the pane should show
109 */
110export function modelOf(run: { exitCode: number; stdout: string; stderr: string }, readAt: number): Model {
111  if (run.exitCode !== 0) {
112    const reason = run.stderr.trim().split('\n').at(-1) ?? ''
113
114    return { kind: 'error', text: `osascript exited ${run.exitCode}${reason ? `: ${reason}` : ''}` }
115  }
116
117  let parsed: unknown
118
119  try {
120    parsed = JSON.parse(run.stdout)
121  } catch {
122    return { kind: 'error', text: 'Music.app answered something that is not JSON' }
123  }
124
125  if (!isRecord(parsed) || typeof parsed.state !== 'string' || !STATES.has(parsed.state)) {
126    return { kind: 'error', text: 'Music.app answered an unknown player state' }
127  }
128
129  const track = isRecord(parsed.track) ? parsed.track : null
130
131  return {
132    kind: 'ok',
133    readAt,
134    now: {
135      state: parsed.state as NowPlaying['state'],
136      track: track
137        ? {
138            name: stringOr(track.name, ''),
139            artist: stringOr(track.artist, ''),
140            album: stringOr(track.album, ''),
141            duration: numberOr(track.duration, 0),
142          }
143        : null,
144      position: typeof parsed.position === 'number' ? parsed.position : null,
145    },
146  }
147}
148
149/**
150 * The reading as it stands at `at`: while playing, the position runs on from
151 * where it was read, so the clock ticks without asking Music.app again.
152 *
153 * @param model what was last read
154 * @param at now, milliseconds since the epoch
155 * @returns the model with the position carried forward, capped at the track's end
156 */
157export function modelAt(model: Model, at: number): Model {
158  if (model.kind !== 'ok' || model.now.state !== 'playing' || !model.now.track || model.now.position === null) {
159    return model
160  }
161
162  const elapsed = Math.max(0, at - model.readAt) / 1000
163  const { duration } = model.now.track
164  const position = model.now.position + elapsed
165
166  return { ...model, now: { ...model.now, position: duration > 0 ? Math.min(duration, position) : position } }
167}
168
169/**
170 * Whether the playing track has run out by `at`, so the next one is due.
171 *
172 * @param model what was last read
173 * @param at now, milliseconds since the epoch
174 * @returns true once the carried-forward position reaches the duration
175 */
176export function hasEnded(model: Model, at: number): boolean {
177  const shown = modelAt(model, at)
178
179  return (
180    shown.kind === 'ok' &&
181    shown.now.state === 'playing' &&
182    shown.now.track !== null &&
183    shown.now.track.duration > 0 &&
184    (shown.now.position ?? 0) >= shown.now.track.duration
185  )
186}
187
188/**
189 * Seconds as m:ss (or h:mm:ss past an hour).
190 *
191 * @param seconds a non-negative duration
192 * @returns the clock text
193 */
194export function clockOf(seconds: number): string {
195  const whole = Math.max(0, Math.floor(seconds))
196  const h = Math.floor(whole / 3600)
197  const m = Math.floor((whole % 3600) / 60)
198  const s = whole % 60
199  const mm = h > 0 ? String(m).padStart(2, '0') : String(m)
200
201  return `${h > 0 ? `${h}:` : ''}${mm}:${String(s).padStart(2, '0')}`
202}
203
204/**
205 * A progress bar of `width` cells, filled to `ratio`.
206 *
207 * @param ratio 0 to 1; out of range is clamped
208 * @param width the bar's cells, at least 1
209 * @returns the bar
210 */
211export function barOf(ratio: number, width: number): string {
212  const cells = Math.max(1, Math.floor(width))
213  const filled = Math.round(Math.min(1, Math.max(0, Number.isFinite(ratio) ? ratio : 0)) * cells)
214
215  return `${'█'.repeat(filled)}${'░'.repeat(cells - filled)}`
216}
217
218
219/** The transport controls the band offers. */
220export type Control = 'playpause' | 'next'
221
222const CONTROL_SCRIPT: Record<Control, string> = {
223  playpause: 'tell application "Music" to playpause',
224  next: 'tell application "Music" to next track',
225}
226
227/**
228 * The argv that sends one transport control to Music.app.
229 *
230 * @param control which control
231 * @returns the argv
232 */
233export function controlArgvOf(control: Control): readonly string[] {
234  return ['osascript', '-e', CONTROL_SCRIPT[control]]
235}
236
hooks/shared-read.ts 83 lines
1/**
2 * One reading of Music.app shared by every session on the machine: each
3 * session looks at a small file under the temporary directory first and runs
4 * osascript only when the reading there is older than it can use, so five
5 * open sessions cost one read per interval, not five.
6 */
7
8/** A finished osascript run, as `$.process.run` resolves it. */
9export type Run = { exitCode: number; stdout: string; stderr: string }
10
11/**
12 * What the shared file holds: when the reading was taken (milliseconds since
13 * the epoch) and the run it came from, or null while the first read is
14 * still under way.
15 */
16export type Entry = { readAt: number; run: Run | null }
17
18/** The shared file's name under the temporary directory. */
19export const CACHE_NAME = 'music-mod/now.json'
20
21/**
22 * The shared file's path.
23 *
24 * @param tmpdir `TMPDIR`, when set
25 * @returns the path under it, else under /tmp
26 */
27export function cachePathOf(tmpdir: string | undefined): string {
28  const dir = tmpdir && tmpdir !== '' ? tmpdir : '/tmp'
29
30  return `${dir.replace(/\/+$/, '')}/${CACHE_NAME}`
31}
32
33function isRun(value: unknown): value is Run {
34  if (typeof value !== 'object' || value === null) {
35    return false
36  }
37
38  const run = value as Record<string, unknown>
39
40  return typeof run.exitCode === 'number' && typeof run.stdout === 'string' && typeof run.stderr === 'string'
41}
42
43/**
44 * The entry a shared file's text stands for.
45 *
46 * @param text the file's content
47 * @returns the entry, or null when the text is not one
48 */
49export function entryOf(text: string): Entry | null {
50  let parsed: unknown
51
52  try {
53    parsed = JSON.parse(text)
54  } catch {
55    return null
56  }
57
58  if (typeof parsed !== 'object' || parsed === null) {
59    return null
60  }
61
62  const { readAt, run } = parsed as Record<string, unknown>
63
64  if (typeof readAt !== 'number' || !Number.isFinite(readAt)) {
65    return null
66  }
67
68  return { readAt, run: isRun(run) ? run : null }
69}
70
71/**
72 * Whether an entry is recent enough to use instead of a new read. An entry
73 * from the future (a clock set back) is never fresh.
74 *
75 * @param entry what the shared file holds
76 * @param at now, milliseconds since the epoch
77 * @param maxAgeMs the oldest reading the caller takes
78 * @returns whether to use it
79 */
80export function isFresh(entry: Entry | null, at: number, maxAgeMs: number): entry is Entry {
81  return entry !== null && entry.readAt <= at && at - entry.readAt < maxAgeMs
82}
83
hooks/views/band-view.tsx 166 lines
1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { Elements, RenderElement } from 'claude-code'
5
6import { barOf, clockOf, type Control, type Model, type NowPlaying } from '../now-playing'
7
8/** The tags the band draws with. */
9export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
10
11/** What a press on each control runs. */
12export type Actions = Record<Control, () => void>
13
14/** The fewest cells the bar keeps; below that the title gives way. */
15export const BAR_MIN_CELLS = 6
16
17/** The state glyph, a pressable play/pause. */
18export const STATE_MARK: Record<NowPlaying['state'], string> = {
19  closed: '🎵',
20  stopped: '▶️',
21  playing: '▶️',
22  paused: '⏸️',
23  'fast forwarding': '⏩',
24  rewinding: '⏪',
25}
26
27/** The next-track glyph. */
28export const NEXT_MARK = '⏭️'
29
30/** Cells an emoji with a variation selector takes on the terminal. */
31const GLYPH_CELLS = 2
32
33/**
34 * Cells the engine draws its collapse mark (` [-]`) over at the band's right
35 * edge; `bodyColumns` does not set them aside, so the line stops short of them.
36 */
37export const COLLAPSE_CELLS = 4
38
39const WIDE =
40  /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}]/u
41
42/**
43 * The columns `text` takes on the terminal, a CJK or emoji character two.
44 *
45 * @param text the string
46 * @returns its cells
47 */
48export function widthOf(text: string): number {
49  let width = 0
50
51  for (const char of text) {
52    width += WIDE.test(char) ? 2 : 1
53  }
54
55  return width
56}
57
58/**
59 * `text` cut to `cells` columns with an ellipsis when it does not fit.
60 *
61 * @param text the string
62 * @param cells the columns to fit
63 * @returns the fitted string
64 */
65export function fitOf(text: string, cells: number): string {
66  if (widthOf(text) <= cells) {
67    return text
68  }
69
70  let width = 0
71  let out = ''
72
73  for (const char of text) {
74    const w = WIDE.test(char) ? 2 : 1
75
76    if (width + w > cells - 1) {
77      return `${out}…`
78    }
79
80    width += w
81    out += char
82  }
83
84  return out
85}
86
87/**
88 * The band's tree for one `ui.render`: one line above the prompt filling its
89 * width (the bar takes what the title leaves), its two glyphs pressable.
90 *
91 * @param kit Box, Text and Button
92 * @param model what was last read
93 * @param columns the band's body columns
94 * @param actions what the glyphs run
95 * @returns the tree
96 */
97export function bandView(kit: Kit, model: Model, columns: number, actions: Actions): RenderElement {
98  const { Box, Text, Button } = kit
99
100  if (model.kind === 'idle') {
101    return (
102      <Box paddingX={1}>
103        <Text dimColor>🎵 Reading Music.app…</Text>
104      </Box>
105    )
106  }
107
108  if (model.kind === 'error') {
109    return (
110      <Box paddingX={1}>
111        <Text wrap="truncate-end">
112          <Text color="red">⚠️ could not read Music.app</Text>
113          <Text dimColor>  {model.text}</Text>
114        </Text>
115      </Box>
116    )
117  }
118
119  const { now } = model
120
121  if (now.state === 'closed') {
122    return (
123      <Box paddingX={1}>
124        <Text dimColor>🎵 music isn't running</Text>
125      </Box>
126    )
127  }
128
129  if (now.state === 'stopped' || !now.track) {
130    return (
131      <Box paddingX={1} flexDirection="row" gap={1}>
132        <Button key="playpause" plain onPress={actions.playpause}>
133          {STATE_MARK.stopped}
134        </Button>
135        <Text dimColor>music is open, nothing playing</Text>
136      </Box>
137    )
138  }
139
140  const { track } = now
141  const position = now.position ?? 0
142  const clocks = `${clockOf(position)} / ${clockOf(track.duration)}`
143  const title = [track.name, track.artist, track.album].filter(part => part !== '').join(' · ')
144  const fixed = 2 + GLYPH_CELLS + 1 + 1 + 1 + clocks.length + 1 + GLYPH_CELLS
145  const spare = Math.max(BAR_MIN_CELLS + 4, columns - COLLAPSE_CELLS - fixed)
146  const isWhole = widthOf(title) + BAR_MIN_CELLS <= spare
147  const titleCells = isWhole ? widthOf(title) : spare - BAR_MIN_CELLS
148  const cells = spare - titleCells
149  const bar = barOf(track.duration > 0 ? position / track.duration : 0, cells)
150
151  return (
152    <Box paddingX={1} flexDirection="row" gap={1}>
153      <Button key="playpause" plain onPress={actions.playpause}>
154        {STATE_MARK[now.state]}
155      </Button>
156      <Text bold>{fitOf(title, titleCells)}</Text>
157      <Text>
158        {bar} <Text dimColor>{clocks}</Text>
159      </Text>
160      <Button key="next" plain onPress={actions.next}>
161        {NEXT_MARK}
162      </Button>
163    </Box>
164  )
165}
166