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

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

/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.♪ title — artist, with ⏸ when paused and ♥ for an Apple Music favorite.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)./tui fullscreen to click the buttons. Hotkeys work either way once the pane has focus.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
| Command | What it does |
|---|---|
/music | Open the pane |
/music n or next | Next track |
/music b, prev or previous | Previous track |
/music k or toggle | Play/pause |
/music play, /music pause | Play or pause |
/music fav | Toggle favorite on the current Apple Music song |
/music shuffle | Toggle shuffle |
/music repeat | Cycle repeat (Apple Music: off, all, one; Spotify: off, all) |
/music playlist | List your Apple Music playlists |
/music playlist <name> | Play a playlist (partial, case-insensitive name) |
/music art blocks | Draw the cover as half-block cells (default) |
/music art image | Draw the cover as a kitty-graphics image |
/music exclusive on, /music exclusive off | Turn exclusive playback on or off |
/music lyrics on, /music lyrics off | Show or hide synced lyrics (on by default) |
Hotkeys in the pane:
| Key | Action |
|---|---|
1, 2 | Switch tab |
b, k, n | Previous, play/pause, next |
d, u | Volume down, up |
f | Favorite (Apple Music only) |
s | Shuffle on or off |
r | Cycle repeat |
l | Lyrics on or off |
o | Open the player when it is closed |
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.
blocks./music lyrics off.MIT
hooks/register.tsx 766 lines1import { 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}
766hooks/player.ts 265 lines1import 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}
265hooks/artwork.ts 134 lines1type 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}
134hooks/layout.ts 72 lines1// 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}
72hooks/lyrics.ts 123 lines1// 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}
123hooks/poller.ts 62 lines1// 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}
62hooks/pane.tsx 170 lines1import 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}
170types/index.d.ts 64 lines1export 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