SLOPSHOPPER

now-playing

Now Playing pane, status line and model tool for Apple Music and Spotify on macOS

newpaneguardcommandtoaststatus
★ 2v0.3.2MITupdated 2026-10-09Fatmylin/now-playing/plugins/now-playing
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · now-playing
│ ┃ Now Playing ✕ › fix the failing auth test and add an audit log call │ ┃ Apple Music is closed. │ ┃ ● now-playing: now-playing: resident poller ended; restarting in 1000 │ ┃ o: Open Apple Music ⏺ 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 │ ⎿ now-playing: Nothing is playing in Music or Spotify. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Now Playing
Apple Music is closed. o: Open Apple Music
README

now-playing

A Now Playing pane for Apple Music and Spotify inside Claude Code.

now-playing in Claude Code: cover art with a spinning record, synced lyrics, and Claude playing a playlist on request

Watch the MP4

Features

  • A centred player pane (/music) with the cover drawn as half-block cells and a spinning vinyl record beside it. The cover is 56, 42 or 28 columns wide, depending on the pane width.
  • Tabs for the players you have installed (Apple Music, Spotify). With one player installed there are no tabs.
  • Transport (previous, play/pause, next), volume, shuffle and repeat (Apple Music: repeat off/all/one; Spotify: repeat off/all), and favorites for Apple Music.
  • A status line showing ♪ title — artist, with ⏸ when paused and ♥ for an Apple Music favorite.
  • A music tool that Claude can call (mcp__now-playing__music): status, play, pause, toggle, next, previous, volume, seek, favorite, unfavorite, list_playlists, play_playlist, shuffle, repeat (optional mode).
  • Play your Apple Music playlists by name, or ask Claude to.
  • Synced lyrics from LRCLIB, the current line highlighted.
  • Exclusive playback (on by default): starting one app pauses the other.
  • Play resumes the current song, or starts your library when nothing is queued.

Requirements

  • macOS
  • Apple Music and/or Spotify desktop app
  • Claude Code 2.1.287 or newer
  • /tui fullscreen to click the buttons. Hotkeys work either way once the pane has focus.

Install

claude plugin marketplace add Fatmylin/now-playing
claude plugin install now-playing@now-playing

To try it from a local checkout:

claude --plugin-dir ./plugins/now-playing

Usage

CommandWhat it does
/musicOpen the pane
/music n or nextNext track
/music b, prev or previousPrevious track
/music k or togglePlay/pause
/music play, /music pausePlay or pause
/music favToggle favorite on the current Apple Music song
/music shuffleToggle shuffle
/music repeatCycle repeat (Apple Music: off, all, one; Spotify: off, all)
/music playlistList your Apple Music playlists
/music playlist <name>Play a playlist (partial, case-insensitive name)
/music art blocksDraw the cover as half-block cells (default)
/music art imageDraw the cover as a kitty-graphics image
/music exclusive on, /music exclusive offTurn exclusive playback on or off
/music lyrics on, /music lyrics offShow or hide synced lyrics (on by default)

Hotkeys in the pane:

KeyAction
1, 2Switch tab
b, k, nPrevious, play/pause, next
d, uVolume down, up
fFavorite (Apple Music only)
sShuffle on or off
rCycle repeat
lLyrics on or off
oOpen the player when it is closed

Permissions

macOS asks whether your terminal may control Music or Spotify (Automation). The prompt appears when Claude Code starts with a player running, because polling starts at session start, not only on the first /music. Allow it. If you denied it, re-enable it under System Settings › Privacy & Security › Automation.

Known limits

  • Playlists are Apple Music only.
  • No Spotify search or favorites. Spotify's AppleScript interface has neither.
  • No kitty images in attached background sessions. Use the default blocks.
  • Covers for streamed Apple Music songs come from Apple's public iTunes Search API. The song's artist and title are sent to itunes.apple.com.
  • With lyrics on (the default), each new song's artist, title, album and length are sent to lrclib.net when it plays while the Now Playing pane is open. Turn this off with /music lyrics off.
  • The plugin API may change between Claude Code releases.

License

MIT

Source 8 files
hooks/register.tsx 766 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ArtMode, Cover, Player, PlayerApp, RepeatMode, Track } from '../types'
5import {
6  ACTIONS,
7  APPS,
8  CONTROL_JXA,
9  FAVORITE_APPLESCRIPT,
10  MUSIC_ARTWORK_APPLESCRIPT,
11  PLAYLISTS_JXA,
12  PLAYMODE_JXA,
13  PLAY_PLAYLIST_JXA,
14  STATUS_JXA,
15  STATUS_LOOP_JXA,
16  describe,
17  formatPlaylists,
18  itunesArtworkUrl,
19  itunesSearchUrl,
20  matchPlaylist,
21  nextRepeat,
22  parsePlaylists,
23  parseStatus,
24  pickActive,
25  toPause,
26} from './player'
27import type { Action, PlaylistInfo } from './player'
28import { accentColor, bmpToHalfBlocks, fromBase64, recordCells } from './artwork'
29import { ART_SIZES, layoutFor, readable } from './layout'
30import { lrclibGetUrl, lrclibSearchUrl, lyricWindow, parseLrc, pickSynced, positionNow, titleCandidates } from './lyrics'
31import type { LyricLine } from './lyrics'
32import { initialPoller, mayRestartForInterval, onResidentEnded, parsePidLine, splitLines } from './poller'
33import { paneTree } from './pane'
34import type { PaneView } from './pane'
35
36const PANE = 'now-playing'
37const PANE_COLUMNS = 84
38const POLL_MS = 2000
39const POLL_CLOSED_MS = 5000
40const SPIN_MS = 150
41const ALIASES: Record<string, string> = { n: 'next', b: 'previous', prev: 'previous', k: 'toggle' }
42const LABELS: Record<PlayerApp, string> = { Music: 'Apple Music', Spotify: 'Spotify' }
43const NEUTRAL = 0x8a8a8a
44
45const trackAtom = atom({ plugin: 'now-playing', key: 'track' } as const, null)
46const playersAtom = atom({ plugin: 'now-playing', key: 'players' } as const, [])
47const sourceAtom = atom({ plugin: 'now-playing', key: 'source' } as const, null)
48const coversAtom = atom({ plugin: 'now-playing', key: 'covers' } as const, {})
49const artModeAtom = atom({ plugin: 'now-playing', key: 'artMode' } as const, 'blocks')
50const tickAtom = atom({ plugin: 'now-playing', key: 'tick' } as const, 0)
51const lyricsOnAtom = atom({ plugin: 'now-playing', key: 'lyricsOn' } as const, true)
52const lyricsRevAtom = atom({ plugin: 'now-playing', key: 'lyricsRev' } as const, 0)
53
54const LRCLIB_AGENT = 'User-Agent: now-playing (https://github.com/Fatmylin/now-playing)'
55const LYRICS_CACHE_MAX = 50
56// Per track id; a miss or a failure is remembered as 'none' for the session.
57const lyricsCache = new Map<string, { status: 'found'; lines: LyricLine[] } | { status: 'none' }>()
58const lyricsLoading = new Set<string>()
59
60let isPolling = false
61// Status results are applied one at a time, so a one-shot poll and a resident line never double-pause.
62let applying: Promise<unknown> = Promise.resolve()
63// A play-mode write waits ~1.5 s for its read-back; presses of that kind meanwhile are refused.
64const playModeBusy = { shuffle: false, repeat: false }
65// When each app's position was read, and whether the 1 s tick should redraw.
66const polledAt: Partial<Record<PlayerApp, number>> = {}
67let isTicking = false
68// Lyrics are looked up only while the pane is mounted: set by its render, cleared on close.
69let isPaneOpen = false
70let lastStatus: string | undefined
71const coverFor: Partial<Record<PlayerApp, string>> = {}
72let coverDir: string | undefined
73// Exclusive playback: what each app was doing at the last poll.
74let isExclusive = true
75let lastStates: Partial<Record<PlayerApp, string>> = {}
76// The record as last drawn, so the spin timer repaints just its cells.
77let theta = 0
78let record: { columns: number; rows: number; label: number; isPlaying: boolean } | null = null
79
80async function osascript($: EngineInterface, script: string, args: string[] = [], lang = 'JavaScript') {
81  return $.process.run(['osascript', '-l', lang, '-e', script, '--', ...args], { timeoutMs: 10000 })
82}
83
84async function curl($: EngineInterface, args: string[]) {
85  return $.process.run(['curl', '-sfL', '--proto', '=https', '--proto-redir', '=https', '--max-time', '10', ...args], { timeoutMs: 15000 })
86}
87
88// The pane is open: poll fast; closed: slowly.
89const wantedMs = () => (isPaneOpen ? POLL_MS : POLL_CLOSED_MS)
90
91async function poll($: EngineInterface): Promise<Track | null> {
92  if (isPolling) return read($, trackAtom)
93  isPolling = true
94  try {
95    const { stdout } = await osascript($, STATUS_JXA)
96    return await applyStatus($, parseStatus(stdout))
97  } catch {
98    return read($, trackAtom)
99  } finally {
100    isPolling = false
101  }
102}
103
104function applyStatus($: EngineInterface, players: Player[]): Promise<Track | null> {
105  const run = applying.then(() => applyNow($, players))
106  applying = run.catch(() => undefined)
107  return run
108}
109
110async function applyNow($: EngineInterface, players: Player[]): Promise<Track | null> {
111  const polledNow = await $.clock.now()
112  // One app just started while another plays: pause the other.
113  if (isExclusive) {
114    for (const app of toPause(lastStates, players)) await osascript($, CONTROL_JXA, [app, 'pause', '0'])
115  }
116  lastStates = Object.fromEntries(players.map(p => [p.app, p.track?.state ?? 'idle']))
117
118  const track = pickActive(players)
119  // The position and the time it was read change together.
120  for (const p of players) polledAt[p.app] = polledNow
121  await update($, playersAtom, () => players)
122  await update($, trackAtom, () => track)
123
124  const status =
125    track === null
126      ? undefined
127      : `${track.state === 'playing' ? '♪' : '⏸'} ${track.name} — ${track.artist}${track.app === 'Music' && track.favorited === true ? ' ♥' : ''}`
128  if (status !== lastStatus) {
129    lastStatus = status
130    $.ui.status(status)
131  }
132  for (const player of players) {
133    if (player.track !== null && player.track.id !== coverFor[player.app]) {
134      coverFor[player.app] = player.track.id
135      // A cover that cannot be fetched is simply not drawn.
136      void fetchCover($, player.track).catch(() => undefined)
137    }
138  }
139  if (isPaneOpen && (await read($, lyricsOnAtom))) {
140    const picked = await read($, sourceAtom)
141    for (const p of players) {
142      if (p.track !== null && (p.app === track?.app || p.app === picked)) void fetchLyrics($, p.track).catch(() => undefined)
143    }
144  }
145  return track
146}
147
148const debug = ($: EngineInterface, text: string) => $.ui.log(text, { to: 'debug' })
149
150// $.clock.sleep belongs to a dispatch; a wait that outlives one uses a timer.
151const wait = ($: EngineInterface, ms: number) => new Promise<void>(resolve => void $.clock.after(ms, resolve))
152
153// A pane that flips open and closed (a render, then a denied blit) must not respawn the child each time.
154let lastSwitchAt = -Infinity
155let isPollerRunning = false
156
157// Kills a stalled resident by pid, but only a pid that still runs our loop script.
158async function killStalled($: EngineInterface, pid: number | undefined) {
159  if (pid === undefined) return
160  const seen = await $.process.run(['ps', '-p', String(pid), '-o', 'command='], { timeoutMs: 5000 })
161  if (seen.exitCode !== 0 || !seen.stdout.includes('fileHandleWithStandardOutput')) {
162    debug($, `now-playing: pid ${pid} is not the resident poller; not killing it`)
163    return
164  }
165  await $.process.run(['kill', String(pid)], { timeoutMs: 5000 })
166}
167
168// One resident osascript at `ms`, one status line per interval. Returns how it
169// ended: 'switch' when the pane opened or closed (the caller restarts it at the
170// other interval; at most once per 30 s), 'ended' when the process ended,
171// errored or stalled (no output for 3 intervals + 10 s).
172async function runResident($: EngineInterface, ms: number): Promise<'switch' | 'ended'> {
173  let buffer = ''
174  let pid: number | undefined
175  const stream = $.process.spawn({ argv: ['osascript', '-l', 'JavaScript', '-e', STATUS_LOOP_JXA, '--', String(ms)] })
176  const it = stream[Symbol.asyncIterator]()
177  try {
178    while (true) {
179      let timer: { cancel: () => void } | undefined
180      const stalled = new Promise<'stall'>(resolve => {
181        timer = $.clock.after(3 * ms + 10_000, () => resolve('stall'))
182      })
183      const got = await Promise.race([it.next(), stalled]).finally(() => timer?.cancel())
184      if (got === 'stall') {
185        debug($, 'now-playing: resident poller silent; restarting it')
186        // return() may queue behind the pending next(), so the child is killed by pid.
187        await killStalled($, pid).catch(() => undefined)
188        void it.return?.(undefined as never).catch(() => undefined)
189        return 'ended'
190      }
191      if (got.done) return 'ended'
192      const chunk = got.value
193      if (chunk.stream !== 'stdout') continue
194      const split = splitLines(buffer, chunk.text)
195      buffer = split.rest
196      for (const line of split.lines) {
197        if (line.trim() === '') continue
198        const first = parsePidLine(line)
199        if (first !== undefined) {
200          pid = first
201          continue
202        }
203        await applyStatus($, parseStatus(line)).catch(() => undefined)
204      }
205      if (wantedMs() !== ms) {
206        const now = await $.clock.now()
207        if (mayRestartForInterval(lastSwitchAt, now)) {
208          lastSwitchAt = now
209          await it.return?.(undefined as never)
210          return 'switch'
211        }
212      }
213    }
214  } catch (error) {
215    debug($, `now-playing: resident poller error: ${String(error)}`)
216    void it.return?.(undefined as never).catch(() => undefined)
217  }
218  return 'ended'
219}
220
221// Lives for the session and never ends on an error: the resident loop,
222// restarted with a backoff when it ends, and one-shot polling for 5 minutes
223// after 3 quick failures.
224async function pollerLoop($: EngineInterface) {
225  if (isPollerRunning) return
226  isPollerRunning = true
227  let state = initialPoller
228  while (true) {
229    try {
230      const startedAt = await $.clock.now()
231      const ended = await runResident($, wantedMs())
232      if (ended === 'switch') {
233        debug($, `now-playing: poll interval now ${wantedMs()} ms`)
234        continue
235      }
236      const now = await $.clock.now()
237      const out = onResidentEnded(state, now, now - startedAt)
238      state = out.state
239      if (out.step.kind === 'restart') {
240        debug($, `now-playing: resident poller ended; restarting in ${out.step.delayMs} ms`)
241        await wait($, out.step.delayMs)
242        continue
243      }
244      debug($, `now-playing: resident poller failed repeatedly; one-shot polling for ${out.step.retryMs} ms`)
245      const until = (await $.clock.now()) + out.step.retryMs
246      while ((await $.clock.now()) < until) {
247        await poll($)
248        await wait($, wantedMs())
249      }
250      debug($, 'now-playing: retrying the resident poller')
251    } catch (error) {
252      debug($, `now-playing: poller loop error: ${String(error)}`)
253      try {
254        await wait($, 5000)
255      } catch {
256        // keep looping: a failed wait must not end the poller
257      }
258    }
259  }
260}
261
262async function fetchLyrics($: EngineInterface, track: Track) {
263  if (lyricsCache.has(track.id) || lyricsLoading.has(track.id)) return
264  lyricsLoading.add(track.id)
265  try {
266    let synced: string | undefined
267    const got = await curl($, ['-H', LRCLIB_AGENT, '-o', '-', '--', lrclibGetUrl(track)])
268    if (got.exitCode === 0) synced = pickSynced(got.stdout)
269    // Lyrics turned off while /get was in flight: no /search, nothing cached.
270    if (synced === undefined && !(await read($, lyricsOnAtom))) return
271    const titles = titleCandidates(track.name)
272    for (let i = 0; i < titles.length && synced === undefined; i++) {
273      // The first search is covered by the check above; re-check before a second.
274      if (i > 0 && !(await read($, lyricsOnAtom))) return
275      const found = await curl($, ['-H', LRCLIB_AGENT, '-o', '-', '--', lrclibSearchUrl(track, titles[i])])
276      if (found.exitCode === 0) synced = pickSynced(found.stdout, track.duration)
277    }
278    const lines = synced === undefined ? [] : parseLrc(synced)
279    lyricsCache.set(track.id, lines.length > 0 ? { status: 'found', lines } : { status: 'none' })
280  } catch {
281    lyricsCache.set(track.id, { status: 'none' })
282  } finally {
283    lyricsLoading.delete(track.id)
284    while (lyricsCache.size > LYRICS_CACHE_MAX) lyricsCache.delete(lyricsCache.keys().next().value!)
285    await update($, lyricsRevAtom, n => n + 1)
286  }
287}
288
289async function setLyricsOn($: EngineInterface, on: boolean) {
290  await update($, lyricsOnAtom, () => on)
291  await $.store.set('lyrics', on)
292}
293
294async function fetchCover($: EngineInterface, track: Track) {
295  if (coverDir === undefined) {
296    const home = (await $.env.get('HOME')) ?? '/tmp'
297    coverDir = `${home}/Library/Caches/claude-now-playing`
298    await $.process.run(['mkdir', '-p', coverDir])
299  }
300  // One file set per app, so the two tabs never overwrite each other's cover.
301  const raw = `${coverDir}/cover-${track.app}.raw`
302  const png = `${coverDir}/cover-${track.app}.png`
303  let isFetched = false
304  if (track.app === 'Music') {
305    isFetched = (await osascript($, MUSIC_ARTWORK_APPLESCRIPT, [raw], 'AppleScript')).exitCode === 0
306    if (!isFetched) {
307      const search = await curl($, ['-o', '-', '--', itunesSearchUrl(track)])
308      const url = search.exitCode === 0 ? itunesArtworkUrl(search.stdout) : undefined
309      isFetched = url !== undefined && (await curl($, ['-o', raw, '--', url])).exitCode === 0
310    }
311  } else if (track.artworkUrl) {
312    isFetched = (await curl($, ['-o', raw, '--', track.artworkUrl])).exitCode === 0
313  }
314  const converted =
315    isFetched
316      ? await $.process.run(['sips', '-s', 'format', 'png', '-Z', '400', raw, '--out', png])
317      : undefined
318  const cells: Cover['cells'] = {}
319  let accent: number | undefined
320  if (converted?.exitCode === 0) {
321    for (const size of ART_SIZES) {
322      const bmp = await coverBmp($, png, size)
323      if (bmp === undefined) continue
324      cells[`${size}`] = bmpToHalfBlocks(bmp, size, size / 2)
325      if (size === 28) accent = accentColor(bmp)
326    }
327  }
328  // A newer track may have started while this one's cover was fetched.
329  if (coverFor[track.app] !== track.id) return
330  await update($, coversAtom, prev => {
331    const { [track.app]: old, ...rest } = prev
332    if (converted?.exitCode !== 0) return rest
333    const cover: Cover = { trackId: track.id, file: png, generation: (old?.generation ?? 0) + 1, cells, accent }
334    return { ...rest, [track.app]: cover }
335  })
336}
337
338// The cover resized by sips to a square BMP of `size` pixels, as bytes.
339async function coverBmp($: EngineInterface, png: string, size: number): Promise<Uint8Array | undefined> {
340  const bmp = png.replace(/\.png$/, `-${size}.bmp`)
341  const resized = await $.process.run(['sips', '-s', 'format', 'bmp', '-z', String(size), String(size), png, '--out', bmp])
342  if (resized.exitCode !== 0) return undefined
343  const { exitCode, stdout } = await $.process.run(['base64', '-i', bmp])
344  return exitCode === 0 ? fromBase64(stdout) : undefined
345}
346
347async function control($: EngineInterface, action: Action, app?: PlayerApp, value?: number) {
348  const target = app ?? (await read($, trackAtom))?.app
349  if (target === undefined) return 'Nothing is playing in Music or Spotify, and no app was named.'
350  const current = (await read($, playersAtom)).find(p => p.app === target)?.track
351  const arg =
352    action === 'volume' && value === undefined ? String(current?.volume ?? 50) : String(value ?? 0)
353  const { stdout, exitCode, stderr } = await osascript($, CONTROL_JXA, [target, action, arg])
354  if (exitCode !== 0) return `${target} ${action} failed: ${stderr.trim()}`
355  if (stdout.trim() === 'not-running') return `${target} is not running.`
356  await poll($)
357  let after = (await read($, playersAtom)).find(p => p.app === target)?.track ?? null
358  // Starting the library takes Music a moment to load its first track.
359  for (let i = 0; i < 6 && after === null && stdout.trim() === 'started-library'; i++) {
360    await $.clock.sleep(500)
361    await poll($)
362    after = (await read($, playersAtom)).find(p => p.app === target)?.track ?? null
363  }
364  return after === null ? `${target} has nothing loaded.` : describe(after)
365}
366
367async function listPlaylists($: EngineInterface): Promise<PlaylistInfo[] | null> {
368  const { exitCode, stdout } = await osascript($, PLAYLISTS_JXA)
369  return exitCode === 0 ? parsePlaylists(stdout) : []
370}
371
372async function playPlaylist($: EngineInterface, query: string): Promise<string> {
373  const lists = await listPlaylists($)
374  if (lists === null) return 'Apple Music is not running.'
375  const match = matchPlaylist(lists, query)
376  if (match.kind === 'none') return `No playlist matches "${query}". Try /music playlist to list them.`
377  if (match.kind === 'many') return `Several playlists match "${query}": ${match.names.join(', ')}. Be more specific.`
378  const { name, count } = match.playlist
379  const { exitCode, stdout, stderr } = await osascript($, PLAY_PLAYLIST_JXA, [name])
380  if (exitCode !== 0) return `Could not play ${name.trim()}: ${stderr.trim()}`
381  if (stdout.trim() === 'not-running') return 'Apple Music is not running.'
382  if (stdout.trim() === 'not-found') return `No playlist matches "${query}". Try /music playlist to list them.`
383  await poll($)
384  return `Playing ${name.trim()} (${count} songs).`
385}
386
387// Apple Music only. The script writes only while `before` is still the
388// current song, then waits ~1 s before reading back.
389async function setFavorite($: EngineInterface, want: boolean): Promise<string> {
390  const before = (await read($, playersAtom)).find(p => p.app === 'Music')?.track ?? null
391  if (before === null) return 'Nothing is playing in Apple Music.'
392  const { exitCode, stdout, stderr } = await osascript($, FAVORITE_APPLESCRIPT, [String(want), before.id], 'AppleScript')
393  if (exitCode !== 0) return `Favorite failed: ${stderr.trim()}`
394  const [id, value] = stdout.trim().split('|')
395  await poll($)
396  if (id === 'not-running') return 'Apple Music is not running.'
397  if (id === 'changed') return 'The song changed before the write; nothing was changed. Try again.'
398  if (id !== before.id) return `The track changed during the write; "${before.name}" was not checked.`
399  const isFavorite = value === 'true'
400  if (isFavorite !== want) return `Favorite did not change: "${before.name}" is ${isFavorite ? '' : 'not '}a favorite.`
401  return isFavorite ? `Added "${before.name}" to Favorites.` : `Removed "${before.name}" from Favorites.`
402}
403
404// Shuffle or repeat on the shown/named app. `mode` sets ("on"/"off", or
405// "off"/"all"/"one"); without it shuffle toggles and repeat cycles. The script
406// waits for the app to settle, and the result is what it read back.
407async function setPlayMode($: EngineInterface, kind: 'shuffle' | 'repeat', mode: string | undefined, app?: PlayerApp): Promise<string> {
408  if (playModeBusy[kind]) return `${kind === 'shuffle' ? 'Shuffle' : 'Repeat'} is still changing; try again in a moment.`
409  playModeBusy[kind] = true
410  try {
411    return await writePlayMode($, kind, mode, app)
412  } finally {
413    playModeBusy[kind] = false
414  }
415}
416
417async function writePlayMode($: EngineInterface, kind: 'shuffle' | 'repeat', mode: string | undefined, app?: PlayerApp): Promise<string> {
418  await poll($)
419  const target = app ?? (await read($, trackAtom))?.app
420  if (target === undefined) return 'Nothing is playing in Music or Spotify, and no app was named.'
421  const current = (await read($, playersAtom)).find(p => p.app === target)?.track
422  const label = LABELS[target]
423  let value: string
424  if (kind === 'shuffle') {
425    value = String(mode === undefined ? !(current?.shuffle ?? false) : mode === 'on')
426  } else {
427    value = mode ?? nextRepeat(target, current?.repeat)
428    if (target === 'Spotify' && value === 'one') return 'Spotify has no repeat-one; use "all" or "off".'
429  }
430  const { exitCode, stdout, stderr } = await osascript($, PLAYMODE_JXA, [target, kind, value])
431  if (exitCode !== 0) return `${kind === 'shuffle' ? 'Shuffle' : 'Repeat'} failed: ${stderr.trim()}`
432  if (stdout.trim() === 'not-running') return `${label} is not running.`
433  await poll($)
434  let back: { shuffle?: boolean; repeat?: RepeatMode }
435  try {
436    back = JSON.parse(stdout) as typeof back
437  } catch {
438    return `${kind === 'shuffle' ? 'Shuffle' : 'Repeat'} failed: unreadable answer from ${label}.`
439  }
440  if (kind === 'shuffle') {
441    const isOn = back.shuffle === true
442    return isOn === (value === 'true') ? `Shuffle ${isOn ? 'on' : 'off'} (${label}).` : `Shuffle did not change (${label}).`
443  }
444  return back.repeat === value
445    ? `Repeat ${back.repeat} (${label}).`
446    : `Repeat did not change (${label}).`
447}
448
449async function openApp($: EngineInterface, app: PlayerApp) {
450  const { exitCode, stderr } = await $.process.run(['open', '-a', app])
451  if (exitCode !== 0) $.ui.toast(`Could not open ${app}: ${stderr.trim()}`)
452  await poll($)
453}
454
455// A play-mode press redraws the pane on success; anything else is a toast.
456async function notify($: EngineInterface, work: Promise<string>, what: string) {
457  try {
458    const result = await work
459    if (!/^(Shuffle|Repeat) (on|off|all|one) \(/.test(result)) $.ui.toast(result)
460  } catch (error) {
461    $.ui.toast(`${what} failed: ${String(error)}`)
462  }
463}
464
465// Redraws the pane once a second, only while it shows a playing track.
466async function tickOnce($: EngineInterface) {
467  if (isTicking) await update($, tickAtom, n => n + 1)
468}
469
470// Turns the record's sheen while the shown app plays; repaints only its cells.
471async function spin($: EngineInterface) {
472  if (record === null || !record.isPlaying) return
473  theta = (theta + 0.22) % (Math.PI * 2)
474  const cells = recordCells(record.columns, record.rows, theta, record.label)
475  const drawn = await $.ui.blit({ requestId: PANE, key: 'record', cells })
476  if (drawn.deny !== undefined) {
477    record = null
478    isTicking = false
479    isPaneOpen = false
480  }
481}
482
483export const register: Register = on => {
484  on('session.start', async ($, e, next) => {
485    await $.command.register({
486      name: 'music',
487      description: 'Now Playing pane for Apple Music / Spotify',
488      argumentHint: '[toggle|next|prev|play|pause|fav|playlist [name]|shuffle|repeat|art blocks|art image|exclusive on|exclusive off|lyrics on|lyrics off]',
489      immediate: true,
490    })
491    await $.tool.register({
492      name: 'music',
493      description:
494        'Read or control what Apple Music or Spotify is playing on this Mac. ' +
495        'action "status" reports the current track; play/pause/toggle/next/previous control playback; ' +
496        '"volume" sets volume to value (0-100); "seek" jumps to value seconds; ' +
497        '"favorite"/"unfavorite" add or remove the current Apple Music song from Favorites. ' +
498        '"shuffle" toggles shuffle and "repeat" cycles repeat (off, all, one; Spotify has no one), or set them with mode ("on"/"off" for shuffle, "off"/"all"/"one" for repeat). ' +
499        '"list_playlists" lists the user\'s Apple Music playlists; "play_playlist" plays one by name (partial names work). ' +
500        'app defaults to whichever app is currently playing.',
501      inputSchema: {
502        type: 'object',
503        properties: {
504          action: { type: 'string', enum: ['status', ...ACTIONS, 'list_playlists', 'play_playlist', 'shuffle', 'repeat'] },
505          app: { type: 'string', enum: [...APPS] },
506          value: { type: 'number' },
507          name: { type: 'string' },
508          mode: { type: 'string' },
509        },
510        required: ['action'],
511      },
512    })
513    const saved = await $.store.get('artMode')
514    if (saved === 'blocks' || saved === 'image') await update($, artModeAtom, () => saved)
515    isExclusive = (await $.store.get('exclusive')) !== false
516    if ((await $.store.get('lyrics')) === false) await update($, lyricsOnAtom, () => false)
517    void pollerLoop($).catch(error => debug($, `now-playing: poller loop stopped: ${String(error)}`))
518    $.clock.every(1000, () => void tickOnce($).catch(() => undefined))
519    $.clock.every(SPIN_MS, () => void spin($).catch(() => undefined))
520
521    return next(e)
522  })
523
524  on('ui.close', ($, e, next) => {
525    if (e.id === PANE) {
526      isTicking = false
527      isPaneOpen = false
528      record = null
529    }
530    return next(e)
531  })
532
533  on('command.run', { command: 'music' }, async ($, e) => {
534    const arg = e.args.trim().toLowerCase()
535    if (arg === '') {
536      await $.ui.open({ id: PANE, title: 'Now Playing', focus: true, columns: PANE_COLUMNS })
537      return { text: describe(await poll($)) }
538    }
539    const art = /^art\s+(blocks|image)$/.exec(arg)?.[1] as ArtMode | undefined
540    if (art !== undefined) {
541      await update($, artModeAtom, () => art)
542      await $.store.set('artMode', art)
543      return { text: `Cover art now drawn as ${art}.` }
544    }
545    const exclusive = /^exclusive\s+(on|off)$/.exec(arg)?.[1]
546    if (exclusive !== undefined) {
547      isExclusive = exclusive === 'on'
548      await $.store.set('exclusive', isExclusive)
549      return {
550        text: isExclusive
551          ? 'Exclusive playback on: starting one app pauses the other.'
552          : 'Exclusive playback off: Music and Spotify can play together.',
553      }
554    }
555    const lyrics = /^lyrics\s+(on|off)$/.exec(arg)?.[1]
556    if (lyrics !== undefined) {
557      await setLyricsOn($, lyrics === 'on')
558      return { text: lyrics === 'on' ? 'Lyrics on.' : 'Lyrics off.' }
559    }
560    const playlist = /^playlists?(?:\s+(.+))?$/i.exec(e.args.trim())
561    if (playlist !== null) {
562      if (playlist[1] === undefined) {
563        const lists = await listPlaylists($)
564        return { text: lists === null ? 'Apple Music is not running.' : formatPlaylists(lists) }
565      }
566      return { text: await playPlaylist($, playlist[1].trim()) }
567    }
568    if (arg === 'fav') {
569      await poll($)
570      const music = (await read($, playersAtom)).find(p => p.app === 'Music')?.track
571      return { text: await setFavorite($, !(music?.favorited ?? false)) }
572    }
573    if (arg === 'shuffle' || arg === 'repeat') return { text: await setPlayMode($, arg, undefined) }
574    const action = ALIASES[arg] ?? arg
575    if (
576      !(ACTIONS as readonly string[]).includes(action) ||
577      ['volume', 'seek', 'favorite', 'unfavorite'].includes(action)
578    ) {
579      return {
580        text: `Unknown /music argument "${arg}". Use toggle (k), next (n), prev (b), play, pause, fav, playlist [name], shuffle, repeat, art blocks|image, exclusive on|off or lyrics on|off.`,
581      }
582    }
583    return { text: await control($, action as Action) }
584  })
585
586  on('tool.call', { tool: 'mcp__now-playing__music' }, async ($, e) => {
587    const action = String(e.action)
588    const app = APPS.find(a => a === e.app)
589    const value = typeof e.value === 'number' ? e.value : undefined
590    if (action === 'status') {
591      const active = await poll($)
592      if (app === undefined) return { result: describe(active) }
593      // A named app reports itself, not whichever app happens to be active.
594      const player = (await read($, playersAtom)).find(p => p.app === app)
595      if (player === undefined || !player.isInstalled) return { result: `${app} is not installed.` }
596      if (!player.isRunning) return { result: `${app} is not running.` }
597      return { result: player.track === null ? `${app} has nothing loaded.` : describe(player.track) }
598    }
599    if (action === 'list_playlists' || action === 'play_playlist') {
600      if (app === 'Spotify') return { deny: 'Playlists are only supported for Apple Music.' }
601      if (action === 'list_playlists') {
602        const lists = await listPlaylists($)
603        return { result: lists === null ? 'Apple Music is not running.' : formatPlaylists(lists) }
604      }
605      if (typeof e.name !== 'string' || e.name.trim() === '') return { deny: 'play_playlist needs a playlist name.' }
606      return { result: await playPlaylist($, e.name.trim()) }
607    }
608    if (action === 'shuffle' || action === 'repeat') {
609      const mode = typeof e.mode === 'string' ? e.mode.trim().toLowerCase() : undefined
610      const allowed = action === 'shuffle' ? ['on', 'off'] : ['off', 'all', 'one']
611      if (mode !== undefined && !allowed.includes(mode)) {
612        return { deny: `Unknown mode "${mode}" for ${action}; use ${allowed.map(m => `"${m}"`).join(', ')}.` }
613      }
614      if (app !== undefined && (await read($, playersAtom)).find(p => p.app === app)?.isInstalled === false) {
615        return { deny: `${app} is not installed.` }
616      }
617      // Refuse before any write: Spotify has no repeat-one.
618      const target = app ?? (await read($, trackAtom))?.app
619      if (action === 'repeat' && mode === 'one' && target === 'Spotify') {
620        return { deny: 'Spotify has no repeat-one; use "all" or "off".' }
621      }
622      return { result: await setPlayMode($, action, mode, app) }
623    }
624    if (!(ACTIONS as readonly string[]).includes(action)) return { deny: `Unknown action "${action}".` }
625    if (action === 'favorite' || action === 'unfavorite') {
626      const active = await read($, trackAtom)
627      if (app === 'Spotify' || (app === undefined && active?.app === 'Spotify')) {
628        return { deny: 'Favorites are only supported for Apple Music.' }
629      }
630      return { result: await setFavorite($, action === 'favorite') }
631    }
632    if ((action === 'volume' || action === 'seek') && value === undefined) {
633      return { deny: `Action "${action}" needs a numeric value.` }
634    }
635    if (app !== undefined && (await read($, playersAtom)).find(p => p.app === app)?.isInstalled === false) {
636      return { deny: `${app} is not installed.` }
637    }
638    return { result: await control($, action as Action, app, value) }
639  })
640
641  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
642    const players = await read($, playersAtom)
643    const active = await read($, trackAtom)
644    const picked = await read($, sourceAtom)
645    const covers = await read($, coversAtom)
646    const artMode = await read($, artModeAtom)
647    await read($, tickAtom) // subscribes the pane to the 1 s redraw
648    const lyricsOn = await read($, lyricsOnAtom)
649    await read($, lyricsRevAtom) // redraws when a lyrics fetch lands
650    const now = await $.clock.now()
651    const width = Math.max(1, e.props.bodyColumns)
652
653    // An empty list means no poll has answered yet, which is not the same as
654    // a poll that found nothing installed.
655    const installed = players.filter(p => p.isInstalled)
656    const shown: PlayerApp = picked ?? active?.app ?? installed[0]?.app ?? 'Music'
657    const player = players.find(p => p.app === shown)
658    const track = player?.track ?? null
659
660    // The pane redraws on success; a control that failed says so in a toast.
661    const press = (action: Action, value?: number) => async () => {
662      try {
663        const result = await control($, action, shown, value)
664        if (!result.startsWith(`${shown} is playing`) && !result.startsWith(`${shown} is paused`)) $.ui.toast(result)
665      } catch (error) {
666        $.ui.toast(`${action} failed: ${String(error)}`)
667      }
668    }
669
670    const cover = covers[shown]
671    const hasCover = track !== null && cover !== undefined && cover.trackId === track.id
672    const accent = readable(hasCover && cover.accent !== undefined ? cover.accent : NEUTRAL)
673    const layout = layoutFor(width)
674
675    const state: PaneView['state'] =
676      players.length > 0 && installed.length === 0 ? 'none-installed' : player === undefined || !player.isRunning ? 'closed' : track === null ? 'stopped' : 'track'
677
678    const position = track === null ? 0 : positionNow(track, polledAt[track.app] ?? now, now)
679    let lyrics: PaneView['lyrics'] = { status: 'off' }
680    if (track !== null && state === 'track' && lyricsOn) {
681      const entry = lyricsCache.get(track.id)
682      lyrics =
683        entry === undefined
684          ? { status: 'loading' }
685          : entry.status === 'none'
686            ? { status: 'none' }
687            : { status: 'found', ...lyricWindow(entry.lines, position) }
688    }
689    isTicking = state === 'track' && track?.state === 'playing'
690    isPaneOpen = true
691
692    // The sleeve: the cover, with the record peeking out from behind it.
693    // Image and Raster are the terminal's alone; the engine draws an Image
694    // only where it detects kitty graphics, so half-block cells are the default.
695    record = null
696    let sleeve = null
697    if (track !== null && state === 'track' && e.surface === 'terminal' && layout.art !== 0) {
698      const { Box, Image, Raster } = $.ui.resolve(e)
699      const size = layout.art
700      const artRows = size / 2
701      const cells = hasCover ? cover.cells[`${size}`] : undefined
702      const art =
703        hasCover && (artMode === 'image' || cells === undefined) ? (
704          <Image
705            key="cover"
706            source={{ file: cover.file, format: 'png', generation: cover.generation }}
707            columns={size}
708            rows={artRows}
709            alt={`${track.album} cover (try /music art blocks)`}
710          />
711        ) : cells !== undefined ? (
712          <Raster key="cover" columns={size} rows={artRows} cells={cells} />
713        ) : null
714      if (art !== null) {
715        record = layout.peek > 0 ? { columns: layout.peek, rows: artRows, label: accent, isPlaying: track.state === 'playing' } : null
716        sleeve = (
717          <Box flexDirection="row">
718            {art}
719            {record !== null && (
720              <Raster key="record" columns={record.columns} rows={record.rows} cells={recordCells(record.columns, record.rows, theta, accent)} />
721            )}
722          </Box>
723        )
724      }
725    }
726
727    const view: PaneView = {
728      width,
729      tabs: installed.map(({ app }) => ({
730        app,
731        label: LABELS[app],
732        isShown: app === shown,
733        isPlaying: players.find(p => p.app === app)?.track?.state === 'playing',
734      })),
735      shown,
736      shownLabel: LABELS[shown],
737      canFavorite: shown === 'Music' && track?.favorited !== undefined,
738      state,
739      track,
740      position,
741      lyrics,
742      accent,
743      layout,
744      sleeve,
745      handlers: {
746        toggleLyrics: () => void setLyricsOn($, !lyricsOn),
747        pickTab: app => void update($, sourceAtom, () => app),
748        open: () => void openApp($, shown),
749        press,
750        toggleShuffle: () => playModeBusy.shuffle || void notify($, setPlayMode($, 'shuffle', undefined, shown), 'Shuffle'),
751        cycleRepeat: () => playModeBusy.repeat || void notify($, setPlayMode($, 'repeat', undefined, shown), 'Repeat'),
752        toggleFavorite: () =>
753          void (async () => {
754            try {
755              const result = await setFavorite($, !(track?.favorited ?? false))
756              if (!result.startsWith('Added') && !result.startsWith('Removed')) $.ui.toast(result)
757            } catch (error) {
758              $.ui.toast(`Favorite failed: ${String(error)}`)
759            }
760          })(),
761      },
762    }
763    return paneTree($.ui.resolve(e), view)
764  })
765}
766
hooks/player.ts 265 lines
1import type { Player, PlayerApp, RepeatMode, Track } from '../types'
2
3export const APPS: readonly PlayerApp[] = ['Music', 'Spotify']
4
5export const ACTIONS = ['play', 'pause', 'toggle', 'next', 'previous', 'volume', 'seek', 'favorite', 'unfavorite'] as const
6export type Action = (typeof ACTIONS)[number]
7
8// JXA: Application(name).running() never launches the app, and a missing app
9// throws, so neither probe opens Music or Spotify behind the person's back.
10const PROBE_JXA = `
11function probe(name) {
12  var a
13  try { a = Application(name) } catch (e) { return { app: name, isInstalled: false, isRunning: false, track: null } }
14  try { if (!a.running()) return { app: name, isInstalled: true, isRunning: false, track: null } }
15  catch (e) { return { app: name, isInstalled: false, isRunning: false, track: null } }
16  try {
17    var state = a.playerState()
18    if (state !== 'playing' && state !== 'paused') return { app: name, isInstalled: true, isRunning: true, track: null }
19    var t = a.currentTrack()
20    var r = { app: name, state: state, name: t.name(), artist: t.artist(), album: t.album(),
21              position: a.playerPosition(), volume: a.soundVolume() }
22    try { r.duration = t.duration() } catch (e) { r.duration = 0 }
23    if (name === 'Spotify') {
24      r.duration = r.duration / 1000; r.id = t.id(); r.artworkUrl = t.artworkUrl()
25      try { r.shuffle = a.shuffling(); r.repeat = a.repeating() ? 'all' : 'off' } catch (e) {}
26    } else {
27      r.id = String(t.persistentID()); try { r.favorited = t.favorited() } catch (e) {}
28      try { r.shuffle = a.shuffleEnabled(); r.repeat = a.songRepeat() } catch (e) {}
29    }
30    return { app: name, isInstalled: true, isRunning: true, track: r }
31  } catch (e) { return { app: name, isInstalled: true, isRunning: true, track: null } }
32}
33`
34
35export const STATUS_JXA = `${PROBE_JXA}function run() { return JSON.stringify([probe('Music'), probe('Spotify')]) }
36`
37
38// One resident osascript: argv[0] is the interval in ms; it writes {"pid":N}
39// once, then one JSON status line per interval, straight to stdout (console.log goes to stderr, and
40// a plain return prints only at exit). One process sends every Apple Event.
41export const STATUS_LOOP_JXA = `${PROBE_JXA}ObjC.import('Foundation')
42function emit(s) {
43  $.NSFileHandle.fileHandleWithStandardOutput.writeData($(s + '\\n').dataUsingEncoding($.NSUTF8StringEncoding))
44}
45function run(argv) {
46  var ms = Number(argv[0])
47  // First line: this process's own pid (an object, never an array), so a stall can be killed by pid.
48  emit(JSON.stringify({ pid: $.NSProcessInfo.processInfo.processIdentifier }))
49  while (true) {
50    emit(JSON.stringify([probe('Music'), probe('Spotify')]))
51    delay(ms / 1000)
52  }
53}
54`
55
56export const CONTROL_JXA = `
57function run(argv) {
58  var a = Application(argv[0]), act = argv[1], v = Number(argv[2])
59  if (!a.running()) return 'not-running'
60  // A stopped Music app has no current track, and a bare play() then does
61  // nothing; start the whole library instead (it holds streamed songs too).
62  var hasTrack = true
63  try { a.currentTrack().name() } catch (e) { hasTrack = false }
64  if (argv[0] === 'Music' && !hasTrack && (act === 'play' || act === 'toggle')) {
65    a.play(a.playlists[0])
66    return 'started-library'
67  }
68  switch (act) {
69    case 'play': a.play(); break
70    case 'pause': a.pause(); break
71    case 'toggle': a.playpause(); break
72    case 'next': a.nextTrack(); break
73    case 'previous': a.previousTrack(); break
74    case 'volume': a.soundVolume = Math.max(0, Math.min(100, Math.round(v))); break
75    case 'seek': a.playerPosition = Math.max(0, v); break
76    default: return 'unknown-action'
77  }
78  return 'ok'
79}
80`
81
82// Sets shuffle or repeat. argv: app, kind ("shuffle" | "repeat"), value
83// ("true"/"false", or "off"/"all"/"one"). It never launches the app. JXA writes
84// land at once but read back stale, so it waits 1.5 s before reading back.
85// Answers "not-running" or JSON {shuffle, repeat} as read back.
86export const PLAYMODE_JXA = `
87function run(argv) {
88  var name = argv[0], kind = argv[1], value = argv[2]
89  var a = Application(name)
90  if (!a.running()) return 'not-running'
91  var isMusic = name === 'Music'
92  if (kind === 'shuffle') {
93    if (isMusic) a.shuffleEnabled = value === 'true'
94    else a.shuffling = value === 'true'
95  } else if (isMusic) a.songRepeat = value
96  else a.repeating = value !== 'off'
97  delay(1.5)
98  if (isMusic) return JSON.stringify({ shuffle: a.shuffleEnabled(), repeat: a.songRepeat() })
99  return JSON.stringify({ shuffle: a.shuffling(), repeat: a.repeating() ? 'all' : 'off' })
100}
101`
102
103// The next repeat mode in the cycle; Spotify has no repeat-one.
104export function nextRepeat(app: PlayerApp, current: RepeatMode | undefined): RepeatMode {
105  if (current === 'off' || current === undefined) return 'all'
106  return current === 'all' && app === 'Music' ? 'one' : 'off'
107}
108
109// JXA writes to `favorited` land late and read back stale, so the write is
110// AppleScript. argv: want ("true"/"false"), expected persistent ID. It never
111// launches Music, and writes only while the expected song is still current.
112// Answers "not-running|", "changed|<current id>" or "<id>|<value>" after a 1 s wait.
113export const FAVORITE_APPLESCRIPT = `
114on run argv
115  if application "Music" is not running then return "not-running|"
116  tell application "Music"
117    set want to (item 1 of argv) is "true"
118    set currentId to persistent ID of current track
119    if currentId is not (item 2 of argv) then return "changed|" & currentId
120    set favorited of current track to want
121    delay 1
122    return (persistent ID of current track) & "|" & (favorited of current track as string)
123  end tell
124end run
125`
126
127export const MUSIC_ARTWORK_APPLESCRIPT = `
128on run argv
129  tell application "Music" to set d to raw data of artwork 1 of current track
130  set fh to open for access (POSIX file (item 1 of argv)) with write permission
131  set eof fh to 0
132  write d to fh
133  close access fh
134end run
135`
136
137export function parseStatus(stdout: string): Player[] {
138  try {
139    const list = JSON.parse(stdout) as unknown
140    return Array.isArray(list)
141      ? list.filter((p): p is Player => p !== null && typeof p === 'object' && APPS.includes((p as Player).app))
142      : []
143  } catch {
144    return []
145  }
146}
147
148// The app that is playing wins; otherwise a paused one, so the controls still
149// have something to resume.
150export function pickActive(players: readonly Player[]): Track | null {
151  const tracks = players.flatMap(p => (p.track === null ? [] : [p.track]))
152  return tracks.find(t => t.state === 'playing') ?? tracks.find(t => t.state === 'paused') ?? null
153}
154
155// Apple Music tracks streamed but not in the library have no artwork that
156// scripting can read, so the cover falls back to Apple's public search API.
157export function itunesSearchUrl(track: Track): string {
158  const term = encodeURIComponent(`${track.artist} ${track.name}`)
159  return `https://itunes.apple.com/search?media=music&entity=song&limit=1&term=${term}`
160}
161
162export function itunesArtworkUrl(stdout: string): string | undefined {
163  try {
164    const body = JSON.parse(stdout) as { results?: { artworkUrl100?: string }[] }
165    return body.results?.[0]?.artworkUrl100?.replace(/\/\d+x\d+bb\./, '/600x600bb.')
166  } catch {
167    return undefined
168  }
169}
170
171// Which app to pause: the ones that were playing while another just started.
172export function toPause(before: Partial<Record<PlayerApp, string>>, players: readonly Player[]): PlayerApp[] {
173  const started = players.filter(p => p.track?.state === 'playing' && before[p.app] !== undefined && before[p.app] !== 'playing')
174  if (started.length === 0) return []
175  return players
176    .filter(p => p.track?.state === 'playing' && !started.some(s => s.app === p.app))
177    .map(p => p.app)
178}
179
180export function formatTime(seconds: number): string {
181  const s = Math.max(0, Math.floor(seconds))
182  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
183}
184
185export function progressBar(position: number, duration: number, width: number): string {
186  const w = Math.max(4, width)
187  if (duration <= 0) return '─'.repeat(w)
188  const at = Math.min(w - 1, Math.floor((position / duration) * w))
189  return '━'.repeat(at) + '●' + '─'.repeat(w - at - 1)
190}
191
192export function describe(track: Track | null): string {
193  if (track === null) return 'Nothing is playing in Music or Spotify.'
194  const time = track.duration > 0 ? ` (${formatTime(track.position)}/${formatTime(track.duration)})` : ''
195  return `${track.app} is ${track.state}: "${track.name}" by ${track.artist} from "${track.album}"${time}, volume ${track.volume}.${track.favorited === true ? ' Favorite.' : ''}${track.shuffle === true ? ' Shuffle on.' : ''}${track.repeat === 'all' || track.repeat === 'one' ? ` Repeat ${track.repeat}.` : ''}`
196}
197
198export type PlaylistInfo = { name: string; count: number; smart: boolean }
199
200// Read-only; never launches Music.
201export const PLAYLISTS_JXA = `
202function run() {
203  var m = Application('Music')
204  if (!m.running()) return 'not-running'
205  var out = []
206  m.userPlaylists().forEach(function (p) {
207    try { out.push({ name: p.name(), count: p.tracks.length, smart: p.smart() }) } catch (e) {}
208  })
209  return JSON.stringify(out)
210}
211`
212
213// Plays the user playlist named exactly argv[0]; never launches Music.
214export const PLAY_PLAYLIST_JXA = `
215function run(argv) {
216  var m = Application('Music')
217  if (!m.running()) return 'not-running'
218  var lists = m.userPlaylists()
219  for (var i = 0; i < lists.length; i++) {
220    if (lists[i].name() === argv[0]) { lists[i].play(); return 'ok' }
221  }
222  return 'not-found'
223}
224`
225
226export function parsePlaylists(stdout: string): PlaylistInfo[] | null {
227  if (stdout.trim() === 'not-running') return null
228  try {
229    const list = JSON.parse(stdout) as unknown
230    return Array.isArray(list)
231      ? list.filter((p): p is PlaylistInfo => p !== null && typeof p === 'object' && typeof (p as PlaylistInfo).name === 'string')
232      : []
233  } catch {
234    return []
235  }
236}
237
238const normal = (s: string) => s.trim().replace(/\s+/g, ' ').toLocaleLowerCase()
239
240// Exact, then prefix, then substring; the first tier with any hit decides.
241export function matchPlaylist(lists: readonly PlaylistInfo[], query: string) {
242  const q = normal(query)
243  if (q === '') return { kind: 'none' } as const
244  const ordered = [...lists.filter(p => !p.smart), ...lists.filter(p => p.smart)]
245  const tiers = [
246    (n: string) => n === q,
247    (n: string) => n.startsWith(q),
248    (n: string) => n.includes(q),
249  ]
250  for (const hit of tiers) {
251    const found = ordered.filter(p => hit(normal(p.name)))
252    if (found.length === 1) return { kind: 'one', playlist: found[0]! } as const
253    if (found.length > 1) return { kind: 'many', names: found.map(p => p.name.trim()) } as const
254  }
255  return { kind: 'none' } as const
256}
257
258export function formatPlaylists(lists: readonly PlaylistInfo[]): string {
259  if (lists.length === 0) return 'No playlists in Apple Music.'
260  const lines = lists.filter(p => !p.smart).map(p => `${p.name.trim()} (${p.count})`)
261  const smart = lists.filter(p => p.smart).map(p => p.name.trim())
262  if (smart.length > 0) lines.push(`Smart playlists: ${smart.join(', ')}`)
263  return lines.join('\n')
264}
265
hooks/artwork.ts 134 lines
1type Bmp = { width: number; height: number; pixel: (x: number, y: number) => number }
2
3// The uncompressed 24/32-bit BMP `sips -s format bmp` writes, top-down or
4// bottom-up, as a pixel reader answering 0xRRGGBB.
5export function readBmp(bmp: Uint8Array): Bmp {
6  const dv = new DataView(bmp.buffer, bmp.byteOffset, bmp.byteLength)
7  const offset = dv.getUint32(10, true)
8  const width = dv.getInt32(18, true)
9  const rawHeight = dv.getInt32(22, true)
10  const bytesPerPixel = dv.getUint16(28, true) / 8
11  const height = Math.abs(rawHeight)
12  const stride = Math.ceil((width * bytesPerPixel) / 4) * 4
13  const pixel = (x: number, y: number) => {
14    const at = offset + (rawHeight < 0 ? y : height - 1 - y) * stride + x * bytesPerPixel
15    return ((bmp[at + 2] ?? 0) << 16) | ((bmp[at + 1] ?? 0) << 8) | (bmp[at] ?? 0)
16  }
17  return { width, height, pixel }
18}
19
20// A cover as Raster cells for terminals the engine draws no Image on: each
21// cell is '▀', its foreground the upper pixel and its background the lower.
22export function bmpToHalfBlocks(bmp: Uint8Array, columns: number, rows: number): string {
23  const { width, height, pixel } = readBmp(bmp)
24  const at = (x: number, y: number) =>
25    pixel(Math.min(width - 1, Math.floor((x * width) / columns)), Math.min(height - 1, Math.floor((y * height) / (rows * 2))))
26  const words = new Uint32Array(columns * rows * 3)
27  for (let y = 0; y < rows; y++) {
28    for (let x = 0; x < columns; x++) {
29      const i = (y * columns + x) * 3
30      words[i] = 0x2580
31      words[i + 1] = at(x, y * 2)
32      words[i + 2] = at(x, y * 2 + 1)
33    }
34  }
35  return toBase64(new Uint8Array(words.buffer))
36}
37
38// The cover's accent: the mean of its most saturated tenth, so a mostly grey
39// sleeve with one red stripe answers red; a fully grey one answers its grey.
40export function accentColor(bmp: Uint8Array): number {
41  const { width, height, pixel } = readBmp(bmp)
42  const scored: { c: number; s: number }[] = []
43  for (let y = 0; y < height; y++) {
44    for (let x = 0; x < width; x++) {
45      const c = pixel(x, y)
46      const r = c >> 16, g = (c >> 8) & 255, b = c & 255
47      const max = Math.max(r, g, b), min = Math.min(r, g, b)
48      // Saturation weighted by brightness, so near-black noise does not win.
49      scored.push({ c, s: max === 0 ? 0 : ((max - min) / max) * (max / 255) })
50    }
51  }
52  scored.sort((a, b) => b.s - a.s)
53  const top = scored.slice(0, Math.max(1, Math.floor(scored.length / 10)))
54  const mean = (shift: number) => Math.round(top.reduce((n, p) => n + ((p.c >> shift) & 255), 0) / top.length)
55  return (mean(16) << 16) | (mean(8) << 8) | mean(0)
56}
57
58const DEFAULT_COLOR = 0x01000000
59
60// The record peeking out from behind the sleeve: the right part of a vinyl
61// disc whose centre sits under the cover, its label in the cover's accent and
62// a sheen across the grooves at angle `theta`, which turns while it plays.
63export function recordCells(columns: number, rows: number, theta: number, label: number): string {
64  const h = rows * 2
65  const radius = h / 2 - 1
66  const cx = columns - 1 - radius
67  const cy = h / 2 - 0.5
68  const px = (x: number, y: number): number => {
69    const dx = x - cx, dy = y - cy
70    const d = Math.sqrt(dx * dx + dy * dy)
71    if (d > radius) return DEFAULT_COLOR
72    if (d < radius * 0.05) return DEFAULT_COLOR
73    if (d < radius * 0.34) return label
74    const a = Math.atan2(dy, dx)
75    const off = Math.abs(((a - theta) % Math.PI + Math.PI * 1.5) % Math.PI - Math.PI / 2)
76    const sheen = off < 0.18 ? 0x30 : off < 0.36 ? 0x14 : 0
77    const groove = Math.floor(d) % 2 === 0 ? 0x0a : 0
78    const v = 0x10 + groove + sheen
79    return (v << 16) | (v << 8) | v
80  }
81  const words = new Uint32Array(columns * rows * 3)
82  for (let y = 0; y < rows; y++) {
83    for (let x = 0; x < columns; x++) {
84      const i = (y * columns + x) * 3
85      const top = px(x, y * 2)
86      const bottom = px(x, y * 2 + 1)
87      // A block's foreground is always drawn, so a default-colour half must
88      // never be the foreground: outside the disc it would show as a stripe
89      // in the terminal's text colour.
90      if (top === DEFAULT_COLOR && bottom === DEFAULT_COLOR) {
91        words[i] = 0x20
92        words[i + 1] = DEFAULT_COLOR
93        words[i + 2] = DEFAULT_COLOR
94      } else if (top === DEFAULT_COLOR) {
95        words[i] = 0x2584
96        words[i + 1] = bottom
97        words[i + 2] = DEFAULT_COLOR
98      } else {
99        words[i] = 0x2580
100        words[i + 1] = top
101        words[i + 2] = bottom
102      }
103    }
104  }
105  return toBase64(new Uint8Array(words.buffer))
106}
107
108const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
109
110export function toBase64(bytes: Uint8Array): string {
111  let out = ''
112  for (let i = 0; i < bytes.length; i += 3) {
113    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
114    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
115    out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
116    out += i + 2 < bytes.length ? B64[n & 63]! : '='
117  }
118  return out
119}
120
121export function fromBase64(text: string): Uint8Array {
122  const clean = text.replace(/[^A-Za-z0-9+/]/g, '')
123  const out = new Uint8Array(Math.floor((clean.length * 3) / 4))
124  let o = 0
125  for (let i = 0; i < clean.length; i += 4) {
126    const n = (B64.indexOf(clean[i]!) << 18) | (B64.indexOf(clean[i + 1] ?? 'A') << 12) |
127      ((B64.indexOf(clean[i + 2] ?? 'A') & 63) << 6) | (B64.indexOf(clean[i + 3] ?? 'A') & 63)
128    if (o < out.length) out[o++] = (n >> 16) & 255
129    if (o < out.length) out[o++] = (n >> 8) & 255
130    if (o < out.length) out[o++] = n & 255
131  }
132  return out
133}
134
hooks/layout.ts 72 lines
1// How the pane sizes its cover: the largest of 56, 42 or 28 columns whose
2// sleeve (cover plus the record peeking out) fits the width.
3export const ART_SIZES = [56, 42, 28] as const
4export type ArtSize = (typeof ART_SIZES)[number]
5// art 0 means the pane is too narrow for any cover.
6export type Layout = { art: ArtSize | 0; peek: number }
7
8export function peekFor(art: number): number {
9  return Math.round(art * 0.42)
10}
11
12export function layoutFor(columns: number): Layout {
13  for (const art of ART_SIZES) {
14    if (art + peekFor(art) <= columns) return { art, peek: peekFor(art) }
15  }
16  return columns < 28 ? { art: 0, peek: 0 } : { art: 28, peek: 0 }
17}
18
19// Terminal columns a character takes: East Asian Wide/Fullwidth are 2.
20const WIDE: [number, number][] = [
21  [0x1100, 0x115f], [0x2e80, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff],
22  [0xfe30, 0xfe4f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x20000, 0x3fffd],
23]
24
25function columnsOf(char: string): number {
26  const code = char.codePointAt(0) ?? 0
27  return WIDE.some(([lo, hi]) => code >= lo && code <= hi) ? 2 : 1
28}
29
30// Text split into lines of at most `width` columns, at spaces when possible.
31// `Text` cannot centre its own wrapped lines, so the pane draws one row each.
32export function wrapLines(text: string, width: number): string[] {
33  const lines: string[] = []
34  let line = ''
35  let used = 0
36  let breakAt = -1 // index in `line` just after the last space
37  for (const char of text) {
38    const w = columnsOf(char)
39    if (used + w > width && line !== '') {
40      if (breakAt > 0 && char !== ' ') {
41        lines.push(line.slice(0, breakAt).trimEnd())
42        line = line.slice(breakAt)
43      } else {
44        lines.push(line.trimEnd())
45        line = ''
46      }
47      used = [...line].reduce((n, c) => n + columnsOf(c), 0)
48      breakAt = -1
49      if (char === ' ' && line === '') continue
50    }
51    line += char
52    used += w
53    if (char === ' ') breakAt = line.length
54  }
55  lines.push(line.trimEnd())
56  return lines.length > 0 ? lines : ['']
57}
58
59// Dark accents vanish on a dark terminal: lift them to a readable brightness.
60export function readable(color: number): number {
61  const r = color >> 16, g = (color >> 8) & 255, b = color & 255
62  const luma = 0.299 * r + 0.587 * g + 0.114 * b
63  if (luma >= 110) return color
64  const k = 110 / Math.max(1, luma)
65  const lift = (c: number) => Math.min(255, Math.round(c * k + 20))
66  return (lift(r) << 16) | (lift(g) << 8) | lift(b)
67}
68
69export function hex(color: number): string {
70  return `#${color.toString(16).padStart(6, '0')}`
71}
72
hooks/lyrics.ts 123 lines
1// Pure helpers for synced lyrics (LRCLIB) and the smoothed play position.
2
3export type LyricLine = { at: number; text: string }
4
5const STAMP = /^\[(\d{1,3}):(\d{2})(?:[.:](\d{1,3}))?\]/
6
7// LRC text to time-sorted lines. Several stamps on one line repeat it; tags
8// such as [ar:..] are skipped; [offset:+ms] shifts every line earlier.
9export function parseLrc(text: string): LyricLine[] {
10  let offset = 0
11  const lines: LyricLine[] = []
12  for (const raw of text.split(/\r?\n/)) {
13    const trimmed = raw.trim()
14    const meta = /^\[offset:\s*([+-]?\d+)\s*\]$/i.exec(trimmed)
15    if (meta !== null) {
16      offset = Number(meta[1]) / 1000
17      continue
18    }
19    const stamps: number[] = []
20    let rest = trimmed
21    for (let m = STAMP.exec(rest); m !== null; m = STAMP.exec(rest)) {
22      const fraction = m[3] === undefined ? 0 : Number(m[3]) / 10 ** m[3].length
23      stamps.push(Number(m[1]) * 60 + Number(m[2]) + fraction)
24      rest = rest.slice(m[0].length)
25    }
26    for (const at of stamps) lines.push({ at, text: rest.trim() })
27  }
28  return lines
29    .map(line => ({ at: Math.max(0, Math.round((line.at - offset) * 1000) / 1000), text: line.text }))
30    .sort((a, b) => a.at - b.at)
31}
32
33export function currentIndex(lines: readonly LyricLine[], position: number): number {
34  let index = -1
35  for (let i = 0; i < lines.length; i++) {
36    if ((lines[i]?.at ?? Infinity) <= position) index = i
37    else break
38  }
39  return index
40}
41
42export function lyricWindow(lines: readonly LyricLine[], position: number) {
43  const i = currentIndex(lines, position)
44  if (i < 0) return { prev: '', current: '', next: lines[0]?.text ?? '' }
45  return { prev: lines[i - 1]?.text ?? '', current: lines[i]?.text ?? '', next: lines[i + 1]?.text ?? '' }
46}
47
48// "Song (Live)" or "Song - Remastered 2011" searches better as "Song".
49export function stripTitleSuffix(title: string): string {
50  const stripped = title
51    .replace(/\s*[([(【][^)\])】]*[)\])】]\s*$/, '')
52    .replace(/\s+-\s+.*$/, '')
53    .trim()
54  return stripped === '' ? title : stripped
55}
56
57const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u
58const TRAILING_PUNCT = /[\s\p{P}]+$/u
59const cjkCount = (text: string) => [...text].filter(c => CJK.test(c)).length
60const TRAILING_LATIN = /\s*[A-Za-z0-9][A-Za-z0-9 .,'!?&:;+_-]*$/
61
62// Search titles to try, in order. A CJK title with an English subtitle glued on
63// ("甲乙丙丁Strangers") is also tried without that trailing Latin run.
64export function titleCandidates(title: string): string[] {
65  const first = stripTitleSuffix(title)
66  const out = [first]
67  if (CJK.test(first)) {
68    const bare = first.replace(TRAILING_LATIN, '').replace(TRAILING_PUNCT, '')
69    // A one-character remainder searches too loosely and can match another song.
70    if (bare !== first && cjkCount(bare) >= 2) out.push(bare)
71  }
72  return out
73}
74
75const enc = encodeURIComponent
76
77export function lrclibGetUrl(t: { artist: string; name: string; album: string; duration: number }): string {
78  return `https://lrclib.net/api/get?artist_name=${enc(t.artist)}&track_name=${enc(t.name)}&album_name=${enc(t.album)}&duration=${Math.round(t.duration)}`
79}
80
81export function lrclibSearchUrl(t: { artist: string; name: string }, title?: string): string {
82  return `https://lrclib.net/api/search?artist_name=${enc(t.artist)}&track_name=${enc(title ?? stripTitleSuffix(t.name))}`
83}
84
85type LrclibRecord = { syncedLyrics?: unknown; instrumental?: unknown; duration?: unknown }
86
87// The synced lyrics of a /get object or the first usable /search result.
88// For a search array, `duration` (seconds) is required and keeps only records within 5 s of it.
89export function pickSynced(jsonText: string, duration?: number): string | undefined {
90  let body: unknown
91  try {
92    body = JSON.parse(jsonText)
93  } catch {
94    return undefined
95  }
96  const isSearch = Array.isArray(body)
97  const items: unknown[] = Array.isArray(body) ? body : body !== null && typeof body === 'object' ? [body] : []
98  const records = items.filter((r): r is LrclibRecord => r !== null && typeof r === 'object')
99  // A search result is trusted only with a length to match against.
100  const hasLength = duration !== undefined && duration > 0
101  if (isSearch && !hasLength) return undefined
102  const matchLength = isSearch
103  const hit = records.find(
104    r =>
105      r.instrumental !== true &&
106      typeof r.syncedLyrics === 'string' &&
107      r.syncedLyrics !== '' &&
108      (!matchLength || (typeof r.duration === 'number' && Math.abs(r.duration - duration!) <= 5)),
109  )
110  return hit?.syncedLyrics as string | undefined
111}
112
113// Where the song is now, from the last poll's position and the time since.
114export function positionNow(
115  t: { state: 'playing' | 'paused'; position: number; duration: number },
116  polledAt: number,
117  now: number,
118): number {
119  if (t.state !== 'playing') return t.position
120  const at = t.position + Math.max(0, now - polledAt) / 1000
121  return t.duration > 0 ? Math.min(t.duration, at) : at
122}
123
hooks/poller.ts 62 lines
1// Pure helpers for the resident status poller: line framing and the
2// restart / fallback policy. Nothing here touches the engine.
3
4export const RESTART_BASE_MS = 1000
5export const RESTART_CAP_MS = 30_000
6export const FALLBACK_AFTER = 3
7export const FAILURE_WINDOW_MS = 60_000
8export const RETRY_RESIDENT_MS = 300_000
9// A resident that streamed this long before ending was healthy: its end is a fresh start.
10export const HEALTHY_RUN_MS = 30_000
11
12// A chunk ends wherever the child's write did: append it, return the whole
13// lines and keep the unfinished tail.
14export function splitLines(buffer: string, chunk: string): { lines: string[]; rest: string } {
15  const parts = (buffer + chunk).split('\n')
16  const rest = parts.pop() ?? ''
17  return { lines: parts, rest }
18}
19
20// 1 s, 2 s, 4 s ... capped at 30 s, by consecutive failure number (1-based).
21export function backoffMs(consecutive: number): number {
22  return Math.min(RESTART_BASE_MS * 2 ** Math.max(0, consecutive - 1), RESTART_CAP_MS)
23}
24
25export type PollerState = { failures: number[]; consecutive: number }
26export const initialPoller: PollerState = { failures: [], consecutive: 0 }
27export type PollerStep = { kind: 'restart'; delayMs: number } | { kind: 'fallback'; retryMs: number }
28
29// The resident process ended or errored at `now` after running `ranMs`.
30// Restart after a backoff; the third failure within 60 s falls back to
31// one-shot polling and retries the resident loop after 5 minutes.
32export function onResidentEnded(state: PollerState, now: number, ranMs: number): { state: PollerState; step: PollerStep } {
33  const base = ranMs >= HEALTHY_RUN_MS ? initialPoller : state
34  const failures = [...base.failures, now].filter(t => now - t < FAILURE_WINDOW_MS)
35  const consecutive = base.consecutive + 1
36  if (failures.length >= FALLBACK_AFTER) {
37    return { state: initialPoller, step: { kind: 'fallback', retryMs: RETRY_RESIDENT_MS } }
38  }
39  return { state: { failures, consecutive }, step: { kind: 'restart', delayMs: backoffMs(consecutive) } }
40}
41
42// Open/closed may flip back and forth (a render, then a denied blit): the
43// resident child is replaced for a new interval at most once per 30 s.
44export const SWITCH_EVERY_MS = 30_000
45export function mayRestartForInterval(lastSwitchAt: number, now: number): boolean {
46  return now - lastSwitchAt >= SWITCH_EVERY_MS
47}
48
49// The resident's first line, `{"pid":N}`; anything else (a status array) is undefined.
50export function parsePidLine(line: string): number | undefined {
51  try {
52    const v = JSON.parse(line) as unknown
53    if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
54      const pid = (v as { pid?: unknown }).pid
55      return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 ? pid : undefined
56    }
57  } catch {
58    // not JSON: a status line parse will say so
59  }
60  return undefined
61}
62
hooks/pane.tsx 170 lines
1import type { Elements, RenderChildren } from 'claude-code'
2
3import type { PlayerApp, Track } from '../types'
4import { hex, wrapLines } from './layout'
5import type { Layout } from './layout'
6import { formatTime } from './player'
7import type { Action } from './player'
8
9export type PaneHandlers = {
10  pickTab: (app: PlayerApp) => void
11  open: () => void
12  press: (action: Action, value?: number) => () => void
13  toggleFavorite: () => void
14  toggleLyrics: () => void
15  toggleShuffle: () => void
16  cycleRepeat: () => void
17}
18export type LyricsView =
19  | { status: 'off' }
20  | { status: 'loading' }
21  | { status: 'none' }
22  | { status: 'found'; prev: string; current: string; next: string }
23export type PaneView = {
24  width: number
25  tabs: { app: PlayerApp; label: string; isShown: boolean; isPlaying: boolean }[]
26  shown: PlayerApp
27  shownLabel: string
28  canFavorite: boolean
29  state: 'none-installed' | 'closed' | 'stopped' | 'track'
30  track: Track | null
31  position: number // seconds, advanced from the last poll
32  lyrics: LyricsView
33  accent: number
34  layout: Layout
35  sleeve: unknown | null // the cover + record row, built in register.tsx (Image/Raster are terminal-only)
36  handlers: PaneHandlers
37}
38
39export function paneTree(ui: Pick<Elements['desktop'], 'Box' | 'Text' | 'Button'>, view: PaneView) {
40  const { Box, Text, Button } = ui
41  const row = (key: string, ...children: RenderChildren[]) => (
42    <Box key={key} flexDirection="row" justifyContent="center" width={view.width}>
43      {children}
44    </Box>
45  )
46  const tabs =
47    view.tabs.length > 1
48      ? row(
49          'row-tabs',
50          <Box flexDirection="row" columnGap={3}>
51            {view.tabs.map((t, i) => (
52              <Button
53                key={`tab-${t.app}`}
54                label={t.isPlaying ? `${t.label} ♪` : t.label}
55                hotkey={String(i + 1)}
56                plain
57                dimColor={!t.isShown}
58                onPress={() => view.handlers.pickTab(t.app)}
59              />
60            ))}
61          </Box>,
62        )
63      : null
64  const label = view.shownLabel
65
66  if (view.state === 'none-installed') {
67    return (
68      <Box flexDirection="column">
69        {row('row-message', <Text dimColor>Install Apple Music or Spotify to use this player.</Text>)}
70      </Box>
71    )
72  }
73  if (view.state === 'closed') {
74    return (
75      <Box flexDirection="column" rowGap={1}>
76        {tabs}
77        {row('row-message', <Text dimColor>{label} is closed.</Text>)}
78        {row('row-action', <Button key="open" label={`Open ${label}`} hotkey="o" plain onPress={view.handlers.open} />)}
79      </Box>
80    )
81  }
82  if (view.state === 'stopped' || view.track === null) {
83    return (
84      <Box flexDirection="column" rowGap={1}>
85        {tabs}
86        {row('row-message', <Text dimColor>{label} is stopped.</Text>)}
87        {row(
88          'row-action',
89          <Button
90            key="toggle"
91            label="▶ Play"
92            hotkey="k"
93            plain
94            onPress={view.handlers.press('play')}
95          />,
96        )}
97      </Box>
98    )
99  }
100
101  const track = view.track
102  const isPlaying = track.state === 'playing'
103  const isShuffle = track.shuffle === true
104  const isRepeat = track.repeat === 'all' || track.repeat === 'one'
105  const played = track.duration > 0 ? Math.min(view.width - 1, Math.floor((view.position / track.duration) * view.width)) : 0
106  return (
107    <Box flexDirection="column">
108      {tabs}
109      <Box height={1} />
110      {view.sleeve !== null && row('row-sleeve', view.sleeve as RenderChildren)}
111      <Box height={1} />
112      {wrapLines(track.name, view.width).map((line, i) => row(`row-title-${i}`, <Text bold>{line}</Text>))}
113      {wrapLines(track.artist, view.width).map((line, i) => row(`row-artist-${i}`, <Text>{line}</Text>))}
114      {wrapLines(track.album, view.width).map((line, i) => row(`row-album-${i}`, <Text dimColor>{line}</Text>))}
115      {view.lyrics.status !== 'off' && <Box height={1} />}
116      {view.lyrics.status === 'found' && row('row-lyric-prev', <Text dimColor wrap="truncate-end">{view.lyrics.prev || ' '}</Text>)}
117      {view.lyrics.status === 'found' && row('row-lyric-current', <Text bold color={hex(view.accent)} wrap="truncate-end">{view.lyrics.current || ' '}</Text>)}
118      {view.lyrics.status === 'found' && row('row-lyric-next', <Text dimColor wrap="truncate-end">{view.lyrics.next || ' '}</Text>)}
119      {(view.lyrics.status === 'none' || view.lyrics.status === 'loading') &&
120        row('row-lyric-none', <Text dimColor>{view.lyrics.status === 'none' ? 'No synced lyrics' : 'Looking for lyrics…'}</Text>)}
121      <Box height={1} />
122      <Box key="row-bar" flexDirection="row" width={view.width}>
123        <Text color={hex(view.accent)}>{'━'.repeat(played)}</Text>
124        <Text color={hex(view.accent)} bold>╸</Text>
125        <Text dimColor>{'─'.repeat(Math.max(0, view.width - played - 1))}</Text>
126      </Box>
127      <Box key="row-times" flexDirection="row" justifyContent="space-between" width={view.width}>
128        <Text dimColor>{formatTime(view.position)}</Text>
129        <Text dimColor>{formatTime(track.duration)}</Text>
130      </Box>
131      <Box height={1} />
132      {row(
133        'row-transport',
134        <Box flexDirection="row" columnGap={4}>
135          <Button key="prev" label="⏮" hotkey="b" plain onPress={view.handlers.press('previous')} />
136          <Button key="toggle" label={isPlaying ? '⏸' : '▶'} hotkey="k" plain onPress={view.handlers.press('toggle')} />
137          <Button key="next" label="⏭" hotkey="n" plain onPress={view.handlers.press('next')} />
138          {view.canFavorite && (
139            <Button key="fav" label={track.favorited === true ? '♥' : '♡'} hotkey="f" plain onPress={view.handlers.toggleFavorite} />
140          )}
141        </Box>,
142      )}
143      {row(
144        'row-playmode',
145        <Box flexDirection="row" columnGap={3}>
146          <Box flexDirection="row" columnGap={1}>
147            {isShuffle ? <Text color={hex(view.accent)}>●</Text> : <Text> </Text>}
148            <Button key="shuffle" label={`⇄ Shuffle ${isShuffle ? 'on' : 'off'}`} hotkey="s" plain dimColor={!isShuffle} onPress={view.handlers.toggleShuffle} />
149            {/* "on" is one column narrower than "off"; keep the row's width fixed */}
150            {isShuffle && <Text> </Text>}
151          </Box>
152          <Box flexDirection="row" columnGap={1}>
153            {isRepeat ? <Text color={hex(view.accent)}>●</Text> : <Text> </Text>}
154            <Button key="repeat" label={`↻ Repeat ${track.repeat ?? 'off'}`} hotkey="r" plain dimColor={!isRepeat} onPress={view.handlers.cycleRepeat} />
155          </Box>
156        </Box>,
157      )}
158      {row(
159        'row-volume',
160        <Box flexDirection="row" columnGap={1}>
161          <Button key="vol-down" label="−" hotkey="d" plain dimColor onPress={view.handlers.press('volume', track.volume - 10)} />
162          <Text dimColor>vol {track.volume}</Text>
163          <Button key="vol-up" label="+" hotkey="u" plain dimColor onPress={view.handlers.press('volume', track.volume + 10)} />
164          <Button key="lyrics" label={view.lyrics.status === 'off' ? 'lyrics off' : 'lyrics on'} hotkey="l" plain dimColor onPress={view.handlers.toggleLyrics} />
165        </Box>,
166      )}
167    </Box>
168  )
169}
170
types/index.d.ts 64 lines
1export type PlayerApp = 'Music' | 'Spotify'
2
3export type RepeatMode = 'off' | 'one' | 'all'
4
5export type Track = {
6  app: PlayerApp
7  state: 'playing' | 'paused'
8  id: string
9  name: string
10  artist: string
11  album: string
12  position: number
13  duration: number
14  volume: number
15  artworkUrl?: string
16  favorited?: boolean // Apple Music only
17  shuffle?: boolean
18  repeat?: RepeatMode // Spotify's repeating is shown as 'all'
19}
20
21// One app as the last poll saw it: closed, open with nothing loaded, or a track.
22export type Player = { app: PlayerApp; isInstalled: boolean; isRunning: boolean; track: Track | null }
23
24// cells: the cover as half-block Raster cells per drawn width (28, 42, 56);
25// accent: the cover's most saturated colour, for the record label and the bar.
26export type Cover = {
27  trackId: string
28  file: string
29  generation: number
30  cells: Partial<Record<'28' | '42' | '56', string>>
31  accent?: number
32}
33
34// blocks: the cover as half-block Raster cells (any truecolor terminal);
35// image: the PNG through the kitty graphics protocol (Ghostty/kitty run directly).
36export type ArtMode = 'blocks' | 'image'
37
38declare module 'claude-code' {
39  interface McpToolInputs {
40    'mcp__now-playing__music': {
41      action: 'status' | 'play' | 'pause' | 'toggle' | 'next' | 'previous' | 'volume' | 'seek' | 'favorite' | 'unfavorite' | 'list_playlists' | 'play_playlist' | 'shuffle' | 'repeat'
42      app?: PlayerApp
43      value?: number
44      name?: string
45      mode?: string
46    }
47  }
48  interface PluginState {
49    'now-playing': {
50      track: Track | null
51      players: Player[]
52      // The tab the person picked; null follows whichever app is playing.
53      source: PlayerApp | null
54      covers: Partial<Record<PlayerApp, Cover>>
55      artMode: ArtMode
56      // Bumped once a second while a track plays, to redraw the pane.
57      tick: number
58      lyricsOn: boolean
59      // Bumped when a lyrics fetch lands, to redraw the pane.
60      lyricsRev: number
61    }
62  }
63}
64