Spotify in Claude Code: connect your account, see what's playing above the prompt, and control playback with /spotify or by asking Claude.

Spotify inside Claude Code. Connect your account, keep a player sidebar next to your session, and control playback with /spotify or by asking Claude ("put on some lofi focus music").
p n b s r m when the sidebar has focus)Playback control needs Spotify Premium; cover art uses macOS's sips.
In a terminal:
claude plugin marketplace add jasonzh0/claudify
claude plugin install claudify@claudify
or inside Claude Code: /plugin marketplace add jasonzh0/claudify, then /plugin install claudify@claudify. Restart Claude Code to load it.
/spotify login
Your browser opens Spotify's consent page; approve it and you're connected.
The built-in claudify Spotify app is in Spotify's development mode, which only serves accounts its owner has added (up to 25). If Spotify refuses your account, ask to be added, or use your own app (about a minute, no limit):
http://127.0.0.1:8888/callback and tick "Web API"/spotify setup <client-id>, then /spotify loginNo client secret is involved anywhere (Authorization Code with PKCE); tokens stay in Claude Code's plugin storage on your machine.
Load a checkout instead of the installed copy with claude --plugin-dir /path/to/claudify, or set CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.
/spotify open the player sidebar
/spotify play [query] resume, or search a track and play it
/spotify playlist <query> search a playlist and play it
/spotify pause | toggle | next | prev
/spotify vol <0-100> | mute
/spotify shuffle on|off | repeat off|context|track
/spotify bg terminal|none|album|#rrggbb
/spotify devices | now | close | logout | help
/spotify setup <client-id> | setup default
claude plugin validate .
claude plugin test .hooks/register.tsx 487 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { NowPlaying } from '../types'
5import { COVER_SIZE, coverFromOutput, coverTint, FETCH_SCRIPT } from './cover'
6import * as spotify from './spotify'
7import type { ApiResult } from './spotify'
8import { Bar, EmptySidebar, ghosttyBackground, ghosttyConfigs, GHOSTTY_DEFAULT_BACKGROUND, Sidebar } from './views'
9import type { Actions } from './views'
10
11type $ = EngineInterface
12type Tokens = { access: string; refresh: string; expiresAt: number }
13
14const PANE = 'spotify'
15const POLL_MS = 5000
16const LOGIN_TIMEOUT_MS = 5 * 60 * 1000
17const REPEAT_NEXT = { off: 'context', context: 'track', track: 'off' } as const
18
19const now = atom({ plugin: 'claudify', key: 'now' } as const, null)
20const isConnected = atom({ plugin: 'claudify', key: 'isConnected' } as const, false)
21const cover = atom({ plugin: 'claudify', key: 'cover' } as const, null)
22const queue = atom({ plugin: 'claudify', key: 'queue' } as const, [])
23// The sidebar's background: 'terminal' (the terminal's own color painted over
24// Claude Code's darker pane), 'none', 'album', or '#rrggbb'.
25const background = atom({ plugin: 'claudify', key: 'background' } as const, 'terminal')
26const terminalColor = atom({ plugin: 'claudify', key: 'terminalColor' } as const, null)
27
28// Whether a poll has seen Spotify since the module loaded: music already
29// playing then leaves the sidebar closed, music starting later opens it.
30let hasPolled = false
31let volumeBeforeMute = 50
32let listener: { return?: () => unknown } | undefined
33
34const quietly = (work: Promise<unknown>) => void work.catch(() => {})
35
36// ---------- session ----------
37
38const clientIdOf = async ($: $) => ((await $.store.get('clientId')) as string | undefined) ?? spotify.DEFAULT_CLIENT_ID
39
40const disconnect = async ($: $) => {
41 await $.store.delete('tokens')
42 await update($, isConnected, () => false)
43 await update($, now, () => null)
44}
45
46// Posts to Spotify's token endpoint and keeps the tokens; an error otherwise.
47type TokenResult = { tokens?: Tokens; status?: number; error?: string }
48
49const requestTokens = async ($: $, fields: Record<string, string>, previousRefresh?: string): Promise<TokenResult> => {
50 const res = await $.http.fetch(spotify.TOKEN_URL, {
51 method: 'POST',
52 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
53 body: spotify.form({ ...fields, client_id: await clientIdOf($) }),
54 })
55 const json = spotify.parseJson(res.text)
56 if (!res.ok || !json?.access_token) return { status: res.status, error: json?.error_description ?? json?.error ?? res.status }
57 const tokens: Tokens = {
58 access: json.access_token,
59 refresh: json.refresh_token ?? previousRefresh,
60 expiresAt: (await $.clock.now()) + (json.expires_in ?? 3600) * 1000,
61 }
62 await $.store.set('tokens', tokens)
63 await update($, isConnected, () => true)
64 return { tokens }
65}
66
67const getAccessToken = async ($: $, force = false) => {
68 const tokens = (await $.store.get('tokens')) as Tokens | undefined
69 if (!tokens) return undefined
70 if (!force && tokens.expiresAt - 60000 > (await $.clock.now())) return tokens.access
71 const got = await requestTokens($, { grant_type: 'refresh_token', refresh_token: tokens.refresh }, tokens.refresh)
72 if (got.status === 400 || got.status === 401) await disconnect($)
73 return got.tokens?.access
74}
75
76// Calls the Web API, refreshing the token once if Spotify says it expired.
77const api = async ($: $, method: string, path: string, body?: unknown): Promise<ApiResult> => {
78 for (let attempt = 0; attempt < 2; attempt++) {
79 const token = await getAccessToken($, attempt > 0)
80 if (!token) break
81 const res = await $.http.fetch(`${spotify.API}${path}`, {
82 method,
83 headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
84 body: body === undefined ? (method === 'GET' ? undefined : '') : JSON.stringify(body),
85 })
86 if (res.status !== 401) return { status: res.status, json: spotify.parseJson(res.text) }
87 }
88 return { status: 401, json: { error: { message: 'Not connected. Run /spotify login.' } } }
89}
90
91// Finishes a login from the redirected URL (or its query); an error or undefined.
92const finishLogin = async ($: $, url: string) => {
93 const params = new URLSearchParams(url.slice(url.indexOf('?') + 1).split(/\s/)[0])
94 if (params.get('error')) return `Spotify login was refused: ${params.get('error')}`
95 const code = params.get('code')
96 if (!code) return 'That URL has no "code" in it.'
97 const verifier = (await $.store.get('verifier')) as string | undefined
98 if (!verifier) return 'No login in progress. Run /spotify login first.'
99 if (params.get('state') !== (await $.store.get('authState'))) return 'Login state did not match; run /spotify login again.'
100
101 const got = await requestTokens($, {
102 grant_type: 'authorization_code',
103 code,
104 redirect_uri: spotify.REDIRECT_URI,
105 code_verifier: verifier,
106 })
107 if (got.error) return `Spotify login failed: ${got.error}`
108 await $.store.delete('verifier')
109 await $.store.delete('authState')
110 // A development-mode app logs in any account but serves only the ones its
111 // owner added, so ask once now rather than fail quietly on every poll.
112 if (spotify.isNotRegistered(await api($, 'GET', '/me'))) {
113 await disconnect($)
114 return spotify.NOT_REGISTERED
115 }
116 quietly(poll($))
117 return undefined
118}
119
120// Listens once on 127.0.0.1:8888 for Spotify's redirect, through the host's nc.
121const listenForCallback = ($: $) => {
122 void listener?.return?.()
123 const stream = $.process.spawn({
124 argv: ['nc', '-l', '127.0.0.1', String(spotify.PORT)],
125 input: spotify.CALLBACK_RESPONSE,
126 })
127 listener = stream as unknown as typeof listener
128 const timeout = $.clock.after(LOGIN_TIMEOUT_MS, () => void listener?.return?.())
129 void (async () => {
130 let seen = ''
131 try {
132 for await (const { stream: pipe, text } of stream) {
133 seen += pipe === 'stdout' ? text : ''
134 const query = seen.match(/GET \/callback\?(\S+) HTTP/)?.[1]
135 if (!query) continue
136 timeout.cancel()
137 $.ui.toast((await finishLogin($, query)) ?? 'Spotify connected. Enjoy the music!', { timeoutMs: 6000 })
138 break
139 }
140 } catch {
141 $.ui.toast('Could not listen for the Spotify login; paste the URL with /spotify code <url>.')
142 }
143 })()
144}
145
146const login = async ($: $) => {
147 const verifier = spotify.randomString(64)
148 const state = spotify.randomString(16)
149 await $.store.set('verifier', verifier)
150 await $.store.set('authState', state)
151 const url = await spotify.authorizeUrl(await clientIdOf($), verifier, state)
152 listenForCallback($)
153 let isOpened = false
154 for (const opener of ['open', 'xdg-open']) {
155 isOpened ||= await $.process.run([opener, url], { timeoutMs: 10000 }).then(r => r.exitCode === 0, () => false)
156 }
157 return `${isOpened ? 'Opened Spotify in your browser — approve access and come back here.' : 'Open this URL to connect Spotify:'}
158${url}
159
160If the browser page does not load after approving, copy its URL and run /spotify code <url>.`
161}
162
163const detectTerminalColor = async ($: $) => {
164 if ((await $.env.get('TERM_PROGRAM')) !== 'ghostty') return null
165 let color: string | undefined
166 for (const path of ghosttyConfigs((await $.env.get('HOME')) ?? '')) {
167 color = (await $.fs.read(path).then(ghosttyBackground, () => undefined)) ?? color
168 }
169 return color ?? GHOSTTY_DEFAULT_BACKGROUND
170}
171
172// ---------- playback ----------
173
174const openPlayer = ($: $) => $.ui.open({ id: PANE, title: 'Spotify', columns: 36 })
175
176const poll = async ($: $) => {
177 if (!(await $.store.get('tokens'))) return
178 const r = await api($, 'GET', '/me/player?additional_types=episode')
179 if (r.status !== 200 && r.status !== 204) return
180 const previous = await read($, now)
181 const state = spotify.parsePlayer(r.json, await $.clock.now())
182 await update($, now, () => state)
183 if (state && previous?.trackId !== state.trackId) {
184 const next = spotify.parseQueue((await api($, 'GET', '/me/player/queue')).json)
185 await update($, queue, () => next)
186 quietly(refreshCover($, state))
187 }
188 if (hasPolled && state?.isPlaying && !previous?.isPlaying) quietly(openPlayer($))
189 hasPolled = true
190}
191
192const refreshCover = async ($: $, state: NowPlaying) => {
193 const url = state.coverUrl
194 if (!url) return update($, cover, () => null)
195 if ((await read($, cover))?.url === url) return
196 const { stdout } = await $.process.run(['sh', '-c', FETCH_SCRIPT, 'sh', url, String(COVER_SIZE)], { timeoutMs: 15000 })
197 await update($, cover, () => coverFromOutput(url, stdout) ?? null)
198}
199
200// Runs a player command; when no device is active, wakes one (opening the
201// desktop app if needed) and retries on it. An error to show, or undefined.
202const control = async ($: $, method: string, path: string, body?: unknown) => {
203 const devices = async () => ((await api($, 'GET', '/me/player/devices')).json?.devices ?? []) as { id: string }[]
204 let r = await api($, method, `/me/player${path}`, body)
205 if (spotify.isNoDevice(r)) {
206 let found = await devices()
207 if (found.length === 0) await $.process.run(['open', '-g', '-a', 'Spotify'], { timeoutMs: 10000 }).catch(() => {})
208 for (let i = 0; i < 6 && found.length === 0; i++) {
209 await $.clock.sleep(1500)
210 found = await devices()
211 }
212 const id = found[0]?.id
213 if (id) r = await api($, method, `/me/player${path}${path.includes('?') ? '&' : '?'}device_id=${id}`, body)
214 }
215 $.clock.after(400, () => quietly(poll($)))
216 return r.status < 300 ? undefined : spotify.explain(r)
217}
218
219// Shows a change at once, before Spotify confirms it, then sends it.
220const optimistic = async ($: $, patch: Partial<NowPlaying>, path: string) => {
221 await update($, now, p => (p ? { ...p, ...patch } : p))
222 return control($, 'PUT', path)
223}
224
225const setVolume = ($: $, level: number) => {
226 const volume = Math.max(0, Math.min(100, Math.round(level)))
227 return optimistic($, { volume }, `/volume?volume_percent=${volume}`)
228}
229
230const toggleMute = async ($: $) => {
231 const volume = (await read($, now))?.volume ?? 0
232 if (volume > 0) volumeBeforeMute = volume
233 return setVolume($, volume > 0 ? 0 : volumeBeforeMute)
234}
235
236const setShuffle = ($: $, shuffle: boolean) => optimistic($, { shuffle }, `/shuffle?state=${shuffle}`)
237const setRepeat = ($: $, repeat: NowPlaying['repeat']) => optimistic($, { repeat }, `/repeat?state=${repeat}`)
238
239const toggle = async ($: $) => {
240 const p = await read($, now)
241 if (!p) return control($, 'PUT', '/play')
242 // Freeze or restart the progress bar where it stands.
243 const at = await $.clock.now()
244 const patch = { isPlaying: !p.isPlaying, progressMs: spotify.progressAt(p, at), fetchedAt: at }
245 return optimistic($, patch, p.isPlaying ? '/pause' : '/play')
246}
247
248const playSearch = async ($: $, query: string, type: 'track' | 'playlist') => {
249 const r = await api($, 'GET', `/search?type=${type}&limit=5&q=${encodeURIComponent(query)}`)
250 const hit = (r.json?.[`${type}s`]?.items ?? []).find(Boolean)
251 if (!hit) return `Nothing found on Spotify for "${query}".`
252 const error = await control($, 'PUT', '/play', type === 'track' ? { uris: [hit.uri] } : { context_uri: hit.uri })
253 quietly(openPlayer($))
254 return error ?? spotify.describeHit(hit, type)
255}
256
257const describeNow = async ($: $) => {
258 await poll($)
259 const playing = await read($, now)
260 return playing ? spotify.describe(playing) : 'Nothing is playing on Spotify right now.'
261}
262
263// ---------- commands ----------
264
265// One entry for both the slash command and the model's tool.
266const run = async ($: $, action: string, arg: string): Promise<string> => {
267 switch (action) {
268 case 'help':
269 return spotify.USAGE
270 case 'setup': {
271 if (!arg) return spotify.SETUP_HELP
272 const isDefault = arg === 'default'
273 await (isDefault ? $.store.delete('clientId') : $.store.set('clientId', arg))
274 await disconnect($) // tokens belong to the app that issued them
275 return `${isDefault ? 'Using the built-in app' : 'Client ID saved'}. Now run /spotify login`
276 }
277 case 'login':
278 return login($)
279 case 'code':
280 return (await finishLogin($, arg)) ?? 'Spotify connected.'
281 case 'logout':
282 await disconnect($)
283 return 'Disconnected from Spotify.'
284 case 'bg':
285 case 'background': {
286 const choice = arg.toLowerCase()
287 if (!['terminal', 'none', 'album'].includes(choice) && !/^#[0-9a-f]{6}$/.test(choice)) {
288 return `Background is ${await read($, background)}. Usage: /spotify bg terminal|none|album|#rrggbb`
289 }
290 await $.store.set('background', choice)
291 await update($, background, () => choice)
292 const terminal = (await read($, terminalColor)) ?? 'transparent'
293 return `Sidebar background: ${{ none: 'transparent', terminal: `terminal (${terminal})` }[choice] ?? choice}.`
294 }
295 case 'close':
296 await $.ui.close({ id: PANE })
297 return 'Player closed.'
298 }
299
300 if (!(await $.store.get('tokens'))) return 'Not connected. Run /spotify login'
301
302 switch (action) {
303 case '':
304 case 'open':
305 case 'player': {
306 const { isPlaced } = await openPlayer($)
307 return `${await describeNow($)}${isPlaced ? '' : '\n(Widen the terminal to see the player sidebar.)'}`
308 }
309 case 'now':
310 case 'status':
311 return describeNow($)
312 case 'play':
313 if (arg) return playSearch($, arg, 'track')
314 quietly(openPlayer($))
315 return (await control($, 'PUT', '/play')) ?? 'Resumed.'
316 case 'playlist':
317 return arg ? playSearch($, arg, 'playlist') : 'Usage: /spotify playlist <search>'
318 case 'pause':
319 return (await control($, 'PUT', '/pause')) ?? 'Paused.'
320 case 'toggle':
321 return (await toggle($)) ?? 'Toggled.'
322 case 'next':
323 case 'skip':
324 return (await control($, 'POST', '/next')) ?? 'Skipped.'
325 case 'prev':
326 case 'previous':
327 case 'back':
328 return (await control($, 'POST', '/previous')) ?? 'Back one track.'
329 case 'vol':
330 case 'volume': {
331 const level = Number(arg)
332 if (!arg || !(level >= 0 && level <= 100)) return 'Usage: /spotify vol <0-100>'
333 return (await setVolume($, level)) ?? `Volume ${Math.round(level)}%.`
334 }
335 case 'mute':
336 return (await toggleMute($)) ?? 'Toggled mute.'
337 case 'shuffle':
338 return (await setShuffle($, arg !== 'off')) ?? `Shuffle ${arg === 'off' ? 'off' : 'on'}.`
339 case 'repeat': {
340 const repeat = arg || REPEAT_NEXT[(await read($, now))?.repeat ?? 'off']
341 if (repeat !== 'off' && repeat !== 'context' && repeat !== 'track') return 'Usage: /spotify repeat off|context|track'
342 return (await setRepeat($, repeat)) ?? `Repeat ${repeat}.`
343 }
344 case 'devices': {
345 const devices: any[] = (await api($, 'GET', '/me/player/devices')).json?.devices ?? []
346 if (devices.length === 0) return 'No Spotify devices found. Open Spotify somewhere first.'
347 return devices.map(d => `${d.is_active ? '▶' : ' '} ${d.name} (${d.type})`).join('\n')
348 }
349 default:
350 return spotify.USAGE
351 }
352}
353
354// The handlers the drawings' buttons call; a failure shows as a toast.
355const actionsFor = ($: $): Actions => {
356 const act = (work: () => Promise<string | undefined>) => () =>
357 void work().then(error => error && $.ui.toast(error), () => {})
358 return {
359 prev: act(() => control($, 'POST', '/previous')),
360 next: act(() => control($, 'POST', '/next')),
361 toggle: act(() => toggle($)),
362 shuffle: act(async () => setShuffle($, !(await read($, now))?.shuffle)),
363 repeat: act(async () => setRepeat($, REPEAT_NEXT[(await read($, now))?.repeat ?? 'off'])),
364 mute: act(() => toggleMute($)),
365 volume: level => act(() => setVolume($, level))(),
366 open: act(async () => void (await openPlayer($))),
367 }
368}
369
370// ---------- hooks ----------
371
372export const register: Register = on => {
373 on('session.start', async ($, e, next) => {
374 const started = await next(e)
375 await $.command.register({
376 name: 'spotify',
377 description: 'Spotify: connect, play, pause, skip, search',
378 argumentHint: '(opens player) | play [query] | pause | next | prev | vol <n> | help',
379 immediate: true,
380 })
381 await $.tool.register({
382 name: 'spotify',
383 description:
384 "Controls the user's Spotify playback. Use when the user asks to play, pause, skip, change volume, " +
385 'or asks what is playing. "play" with a query searches for a track; "playlist" searches a playlist ' +
386 '(good for moods like "lofi focus" or "deep work"). "setup" saves the Client ID given as query; ' +
387 '"login" opens the browser to connect the account.',
388 inputSchema: {
389 type: 'object',
390 properties: {
391 action: {
392 type: 'string',
393 enum: ['now', 'play', 'playlist', 'pause', 'next', 'prev', 'volume', 'mute', 'shuffle', 'repeat', 'open', 'setup', 'login'],
394 },
395 query: {
396 type: 'string',
397 description:
398 'Search text for play/playlist, a number for volume, on/off for shuffle, off/context/track for repeat, the Client ID for setup',
399 },
400 },
401 required: ['action'],
402 },
403 })
404
405 const saved = await $.store.get('background')
406 if (typeof saved === 'string') await update($, background, () => saved)
407 const detected = await detectTerminalColor($)
408 await update($, terminalColor, () => detected)
409 await update($, isConnected, () => false)
410 if (await $.store.get('tokens')) {
411 await update($, isConnected, () => true)
412 quietly(poll($))
413 }
414
415 $.clock.every(POLL_MS, () => quietly(poll($)))
416 // Moves the progress bars along between polls.
417 $.clock.every(1000, () => quietly(read($, now).then(p => p?.isPlaying && $.ui.invalidate('ui.render'))))
418 return started
419 })
420
421 on('command.run', { command: 'spotify' }, async ($, e) => {
422 const [action = '', ...rest] = e.args.trim().split(/\s+/)
423 return { text: await run($, action.toLowerCase(), rest.join(' ')) }
424 })
425
426 on('tool.call', { tool: 'mcp__claudify__spotify' }, async ($, e) => {
427 const input = e as unknown as { action?: string; query?: string }
428 return { result: await run($, input.action ?? 'now', input.query?.trim() ?? '') }
429 })
430
431 // The bar shows only while the sidebar is off screen: redraw on either change.
432 on('ui.open', async ($, e, next) => {
433 const opened = await next(e)
434 $.ui.invalidate('ui.render')
435 return opened
436 })
437
438 on('ui.close', async ($, e, next) => {
439 const closed = await next(e)
440 $.ui.invalidate('ui.render')
441 return closed
442 })
443
444 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
445 const playing = await read($, now)
446 if (e.props.hasSurvey || !playing) return next(e)
447 if ((await $.ui.panes()).some(p => p.id === PANE && p.isPlaced && p.isShown)) return next(e)
448 return Bar({
449 el: $.ui.resolve(e),
450 playing,
451 progress: spotify.progressAt(playing, await $.clock.now()),
452 act: actionsFor($),
453 columns: Math.max(20, e.props.bodyColumns ?? e.viewport?.columns ?? 80),
454 })
455 })
456
457 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
458 const el = $.ui.resolve(e)
459 const art = await read($, cover)
460 const choice = await read($, background)
461 const fill = {
462 none: undefined,
463 terminal: (await read($, terminalColor)) ?? undefined,
464 album: art ? coverTint(art) : undefined,
465 }[choice] ?? (choice.startsWith('#') ? choice : undefined)
466 // The root box spans the whole body so a background covers it all.
467 const frame = {
468 width: Math.max(16, e.props.bodyColumns),
469 ...(e.props.scroll?.bodyRows ? { minHeight: e.props.scroll.bodyRows } : {}),
470 ...(fill ? { backgroundColor: fill } : {}),
471 }
472
473 const playing = await read($, now)
474 if (!playing) return EmptySidebar({ el, frame, isConnected: await read($, isConnected) })
475 return Sidebar({
476 el,
477 playing,
478 progress: spotify.progressAt(playing, await $.clock.now()),
479 act: actionsFor($),
480 frame,
481 art,
482 queue: await read($, queue),
483 Raster: 'Raster' in el ? el.Raster : undefined,
484 })
485 })
486}
487hooks/cover.ts 113 lines1import type { Cover } from '../types'
2
3// The resolution a cover is kept at; drawings sample it down to their width.
4export const COVER_SIZE = 40
5
6// Downloads the album art and has macOS's sips scale it to a BMP, which is
7// trivial to decode here (the hooks environment has no image decoder).
8export const FETCH_SCRIPT =
9 'd=$(mktemp -d) && curl -sfL "$1" -o "$d/c.jpg" && ' +
10 'sips -s format bmp -z "$2" "$2" "$d/c.jpg" --out "$d/c.bmp" >/dev/null && ' +
11 'base64 -i "$d/c.bmp"; rm -rf "$d"'
12
13const decodeBase64 = (text: string) => {
14 const raw = atob(text.replace(/\s+/g, ''))
15 const bytes = new Uint8Array(raw.length)
16 for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i)
17 return bytes
18}
19
20const encodeBase64 = (bytes: Uint8Array) => {
21 let s = ''
22 for (let i = 0; i < bytes.length; i += 0x8000) {
23 s += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
24 }
25 return btoa(s)
26}
27
28// Reads an uncompressed 24- or 32-bit BMP into 0xRRGGBB pixels, top row first.
29const decodeBmp = (bytes: Uint8Array): { width: number; height: number; pixels: number[] } | undefined => {
30 if (bytes[0] !== 0x42 || bytes[1] !== 0x4d) return undefined
31 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
32 const offset = view.getUint32(10, true)
33 const width = view.getInt32(18, true)
34 const rawHeight = view.getInt32(22, true)
35 const bpp = view.getUint16(28, true)
36 if (bpp !== 24 && bpp !== 32) return undefined
37 const height = Math.abs(rawHeight)
38 const isBottomUp = rawHeight > 0
39 const stride = Math.ceil((bpp * width) / 32) * 4
40 const step = bpp / 8
41 const pixels: number[] = []
42 for (let y = 0; y < height; y++) {
43 const row = offset + (isBottomUp ? height - 1 - y : y) * stride
44 for (let x = 0; x < width; x++) {
45 const at = row + x * step
46 pixels.push(((bytes[at + 2] ?? 0) << 16) | ((bytes[at + 1] ?? 0) << 8) | (bytes[at] ?? 0))
47 }
48 }
49 return { width, height, pixels }
50}
51
52// The cover a run of FETCH_SCRIPT printed, or undefined when it printed none.
53export const coverFromOutput = (url: string, stdout: string): Cover | undefined => {
54 const image = stdout.trim() ? decodeBmp(decodeBase64(stdout)) : undefined
55 if (!image || image.width !== image.height) return undefined
56 return { url, size: image.width, pixels: image.pixels }
57}
58
59const rgb = (p: number) => [(p >> 16) & 0xff, (p >> 8) & 0xff, p & 0xff] as const
60const pack = (r: number, g: number, b: number) => ((Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b)) >>> 0
61
62// The average color of some pixels.
63const average = (pixels: number[]) => {
64 const sum = [0, 0, 0]
65 for (const p of pixels) rgb(p).forEach((v, i) => (sum[i]! += v))
66 const n = Math.max(1, pixels.length)
67 return sum.map(v => v / n) as [number, number, number]
68}
69
70// The cover's color over cell (x, y) of a size×size grid laid over it.
71const sample = (cover: Cover, size: number, x: number, y: number) => {
72 const scale = cover.size / size
73 const [x0, y0] = [Math.floor(x * scale), Math.floor(y * scale)]
74 const [x1, y1] = [Math.max(x0 + 1, Math.floor((x + 1) * scale)), Math.max(y0 + 1, Math.floor((y + 1) * scale))]
75 const pixels: number[] = []
76 for (let yy = y0; yy < y1; yy++) for (let xx = x0; xx < x1; xx++) pixels.push(cover.pixels[yy * cover.size + xx] ?? 0)
77 return pack(...average(pixels))
78}
79
80// Raster cells for a cover `columns` wide: each cell an upper half block, its
81// foreground the top pixel and its background the bottom one.
82export const coverCells = (cover: Cover, columns: number) => {
83 const words: number[] = []
84 for (let row = 0; row < columns / 2; row++) {
85 for (let x = 0; x < columns; x++) words.push(0x2580, sample(cover, columns, x, row * 2), sample(cover, columns, x, row * 2 + 1))
86 }
87 return encodeBase64(new Uint8Array(Uint32Array.from(words).buffer))
88}
89
90// The cover as ASCII art, one character per cell, for surfaces without Raster.
91export const coverAscii = (cover: Cover, columns: number) => {
92 const ramp = ' .:-=+*#%@'
93 const luma = (p: number) => {
94 const [r, g, b] = rgb(p)
95 return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 256
96 }
97 return Array.from({ length: columns / 2 }, (_, row) =>
98 Array.from({ length: columns }, (_, x) => {
99 // A cell is two pixels tall: average their brightness.
100 const level = (luma(sample(cover, columns, x, row * 2)) + luma(sample(cover, columns, x, row * 2 + 1))) / 2
101 return ramp[Math.min(ramp.length - 1, Math.floor(level * ramp.length))]
102 }).join(''),
103 )
104}
105
106// A dark shade of the cover's average color, for a background that keeps the
107// sidebar's text readable: the hue kept, the brightest channel brought to 52.
108export const coverTint = (cover: Cover) => {
109 const color = average(cover.pixels)
110 const scale = 52 / Math.max(1, ...color)
111 return `#${pack(...(color.map(v => Math.min(255, v * scale)) as [number, number, number])).toString(16).padStart(6, '0')}`
112}
113hooks/spotify.ts 147 lines1// Spotify's side of things that needs no engine: login URLs, parsing the Web
2// API's answers, and the text the plugin shows.
3import type { NowPlaying } from '../types'
4
5export const PORT = 8888
6export const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`
7export const API = 'https://api.spotify.com/v1'
8export const TOKEN_URL = 'https://accounts.spotify.com/api/token'
9const SCOPES = 'user-read-playback-state user-modify-playback-state user-read-currently-playing'
10
11// The claudify Spotify app. PKCE needs no secret, so a Client ID is safe to
12// ship; Spotify lets an app in development mode serve the 25 users its owner
13// adds in the dashboard, so anyone else brings their own with /spotify setup.
14export const DEFAULT_CLIENT_ID = '82881264abd946b889b4d5bca7d72446'
15
16export type ApiResult = { status: number; json: any }
17
18export const SETUP_HELP = `Use your own Spotify app (about a minute, no user limit):
19 1. Open https://developer.spotify.com/dashboard and create an app.
20 2. Add the Redirect URI ${REDIRECT_URI} and tick "Web API".
21 3. Copy the app's Client ID and run: /spotify setup <client-id>
22 4. Run /spotify login
23To go back to the built-in app: /spotify setup default`
24
25export const NOT_REGISTERED = `Spotify refused this account for the built-in claudify app: while it is in
26development mode Spotify only lets accounts its owner has added use it.
27Ask the owner to add your Spotify email, or use your own app:
28
29${SETUP_HELP}`
30
31export const USAGE = `/spotify open the player sidebar
32/spotify now what is playing
33/spotify close close the player sidebar
34/spotify play [query] resume, or search a track and play it
35/spotify playlist <q> search a playlist and play it
36/spotify pause | toggle | next | prev
37/spotify vol <0-100> | mute
38/spotify shuffle on|off | repeat off|context|track
39/spotify bg terminal|none|album|#rrggbb sidebar background
40/spotify devices list devices
41/spotify login | logout connect or disconnect your account
42/spotify setup <id> use your own Spotify app (setup default: the built-in one)
43/spotify code <url> finish login by pasting the redirected URL`
44
45// The page the browser shows after Spotify redirects back, as nc's reply.
46const PAGE =
47 '<html><body style="font-family:system-ui;text-align:center;padding-top:4em">' +
48 '<h2>Spotify connected to Claude Code</h2><p>You can close this tab.</p></body></html>'
49export const CALLBACK_RESPONSE =
50 'HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n' +
51 `Content-Length: ${new TextEncoder().encode(PAGE).length}\r\n\r\n${PAGE}`
52
53export const form = (fields: Record<string, string>) => new URLSearchParams(fields).toString()
54
55export const randomString = (length: number) => {
56 const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
57 return Array.from(crypto.getRandomValues(new Uint8Array(length)), b => chars[b % chars.length]).join('')
58}
59
60export const authorizeUrl = async (clientId: string, verifier: string, state: string) => {
61 const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)))
62 const challenge = btoa(String.fromCharCode(...digest)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
63 return `https://accounts.spotify.com/authorize?${form({
64 response_type: 'code',
65 client_id: clientId,
66 scope: SCOPES,
67 redirect_uri: REDIRECT_URI,
68 code_challenge_method: 'S256',
69 code_challenge: challenge,
70 state,
71 })}`
72}
73
74export const parseJson = (text: string) => {
75 try {
76 return text ? JSON.parse(text) : null
77 } catch {
78 return null
79 }
80}
81
82const names = (artists?: { name: string }[]) => artists?.map(a => a.name).join(', ')
83
84// GET /me/player's answer as the player's state, or null when nothing plays.
85export const parsePlayer = (json: any, fetchedAt: number): NowPlaying | null => {
86 const item = json?.item
87 if (!item) return null
88 const images: { url: string; width: number }[] = item.album?.images ?? item.images ?? []
89 return {
90 trackId: item.id ?? item.uri ?? item.name,
91 title: item.name,
92 artist: names(item.artists) ?? item.show?.name ?? '',
93 album: item.album?.name ?? item.show?.name ?? '',
94 // The smallest image at least 64px wide: the cover is drawn at 40px.
95 coverUrl: [...images].sort((a, b) => a.width - b.width).find(i => i.width >= 64)?.url ?? images[0]?.url ?? null,
96 isPlaying: Boolean(json.is_playing),
97 progressMs: json.progress_ms ?? 0,
98 durationMs: item.duration_ms ?? 0,
99 fetchedAt,
100 device: json.device?.name ?? '',
101 volume: json.device?.volume_percent ?? null,
102 shuffle: Boolean(json.shuffle_state),
103 repeat: json.repeat_state ?? 'off',
104 }
105}
106
107export const parseQueue = (json: any): string[] =>
108 ((json?.queue ?? []) as any[]).slice(0, 5).map(t => `${t.name} — ${names(t.artists) ?? t.show?.name ?? ''}`)
109
110export const describeHit = (hit: any, type: 'track' | 'playlist') => {
111 const by = type === 'track' ? names(hit.artists) : hit.owner?.display_name
112 return `Playing ${type === 'playlist' ? 'playlist ' : ''}${hit.name}${by ? ` — ${by}` : ''}`
113}
114
115export const describe = (p: NowPlaying) =>
116 `${p.isPlaying ? 'Playing' : 'Paused'}: ${p.title} — ${p.artist} (${clock(p.progressMs)}/${clock(p.durationMs)})` +
117 (p.device ? ` on ${p.device}` : '') +
118 (p.volume === null ? '' : `, volume ${p.volume}%`)
119
120export const isNotRegistered = (r: ApiResult) => r.status === 403 && /registered/i.test(r.json?.error?.message ?? '')
121
122export const isNoDevice = (r: ApiResult) => r.json?.error?.reason === 'NO_ACTIVE_DEVICE' || r.status === 404
123
124// Why a player command failed, in words the person can act on.
125export const explain = (r: ApiResult) => {
126 if (isNotRegistered(r)) return NOT_REGISTERED
127 if (r.json?.error?.reason === 'PREMIUM_REQUIRED' || r.status === 403) {
128 return 'Spotify only allows playback control for Premium accounts.'
129 }
130 if (isNoDevice(r)) return 'No active Spotify device. Open Spotify on any device and try again.'
131 return r.json?.error?.message ?? `Spotify answered ${r.status}.`
132}
133
134export const clock = (ms: number) => {
135 const s = Math.max(0, Math.floor(ms / 1000))
136 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
137}
138
139// The track's position at `at`, moved on from the last poll while it plays.
140export const progressAt = (p: NowPlaying, at: number) =>
141 Math.min(p.durationMs, p.progressMs + (p.isPlaying ? Math.max(0, at - p.fetchedAt) : 0))
142
143export const progressBar = (progress: number, duration: number, width: number) => {
144 const at = duration ? Math.min(width - 1, Math.floor((progress / duration) * width)) : 0
145 return `${'━'.repeat(at)}●${'─'.repeat(Math.max(0, width - at - 1))}`
146}
147hooks/views.tsx 204 lines1// What the plugin draws: the sidebar and the bar above the prompt. Each takes
2// the surface's elements and the button handlers, so it needs no engine.
3import type { ElementTable, Elements } from 'claude-code'
4
5import type { Cover, NowPlaying } from '../types'
6import { coverAscii, coverCells } from './cover'
7import { clock, progressBar } from './spotify'
8
9export type Actions = {
10 prev: () => void
11 next: () => void
12 toggle: () => void
13 shuffle: () => void
14 repeat: () => void
15 mute: () => void
16 volume: (level: number) => void
17 open: () => void
18}
19
20type Shared = { el: ElementTable; playing: NowPlaying; progress: number; act: Actions }
21
22const Title = ({ el, playing }: Shared) => {
23 const { Box, Text } = el
24 return (
25 <Box flexShrink={1}>
26 <Text color="green">♫ </Text>
27 <Text bold wrap="truncate-end">
28 {playing.title}
29 </Text>
30 <Text dimColor wrap="truncate-end">
31 {' '}— {playing.artist}
32 </Text>
33 </Box>
34 )
35}
36
37// The bar above the prompt: one compact row on the left, below a blank row.
38export const Bar = (props: Shared & { columns: number }) => {
39 const { el, playing, progress, act, columns } = props
40 const { Box, Button, Text } = el
41 const barWidth = Math.min(20, columns - 90)
42 return (
43 <Box marginTop={1} gap={2}>
44 {Title(props)}
45 <Text dimColor>
46 {barWidth > 4 ? `${progressBar(progress, playing.durationMs, barWidth)} ` : ''}
47 {clock(progress)}/{clock(playing.durationMs)}
48 </Text>
49 <Box gap={1} flexShrink={0}>
50 <Button key="prev" label="⏮" onPress={act.prev} />
51 <Button key="toggle" label={playing.isPlaying ? '⏸' : '▶'} variant="primary" onPress={act.toggle} />
52 <Button key="next" label="⏭" onPress={act.next} />
53 <Button key="open" label="☰" onPress={act.open} />
54 </Box>
55 </Box>
56 )
57}
58
59type Frame = { width: number; minHeight?: number; backgroundColor?: string }
60
61export const EmptySidebar = ({ el, frame, isConnected }: { el: ElementTable; frame: Frame; isConnected: boolean }) => {
62 const { Box, Text } = el
63 return (
64 <Box flexDirection="column" alignItems="center" paddingY={1} {...frame}>
65 <Text color="green">♫ Spotify</Text>
66 <Text dimColor>{isConnected ? 'Nothing playing.' : 'Not connected.'}</Text>
67 <Text dimColor>{isConnected ? 'Try /spotify play <song>' : 'Run /spotify login'}</Text>
68 </Box>
69 )
70}
71
72type SidebarProps = Shared & {
73 frame: Frame
74 art: Cover | null
75 queue: string[]
76 // Present on the terminal, the one surface that draws colored cells.
77 Raster?: Elements['terminal']['Raster']
78}
79
80const Artwork = ({ el, art, Raster, columns }: Pick<SidebarProps, 'el' | 'art' | 'Raster'> & { columns: number }) => {
81 const { Box, Text } = el
82 if (art && Raster) return <Raster key="cover" columns={columns} rows={columns / 2} cells={coverCells(art, columns)} />
83 if (art) {
84 return (
85 <Box flexDirection="column">
86 {coverAscii(art, columns).map(line => (
87 <Text dimColor>{line}</Text>
88 ))}
89 </Box>
90 )
91 }
92 return (
93 <Box width={columns} height={columns / 2} borderStyle="round" borderDimColor justifyContent="center" alignItems="center">
94 <Text dimColor>♫</Text>
95 </Box>
96 )
97}
98
99// Ten clickable steps of 10%, between a mute toggle and -/+ buttons.
100const Volume = ({ el, volume, act }: { el: ElementTable; volume: number | null; act: Actions }) => {
101 const { Box, Button, Text } = el
102 if (volume === null) return <Text dimColor>Volume is fixed on this device</Text>
103 return (
104 <Box marginTop={1} gap={1}>
105 <Button key="mute" label={volume === 0 ? '🔇' : '🔊'} hotkey="m" plain onPress={act.mute} />
106 <Button key="vol-down" label="−" plain onPress={() => act.volume(volume - 10)} />
107 <Box>
108 {Array.from({ length: 10 }, (_, i) => (
109 <Button
110 key={`vol-${i}`}
111 label={(i + 1) * 10 <= volume ? '█' : '░'}
112 plain
113 dimColor={(i + 1) * 10 > volume}
114 onPress={() => act.volume((i + 1) * 10)}
115 />
116 ))}
117 </Box>
118 <Button key="vol-up" label="+" plain onPress={() => act.volume(volume + 10)} />
119 <Text dimColor>{String(volume).padStart(3)}%</Text>
120 </Box>
121 )
122}
123
124// The sidebar: cover art, track, progress, transport, volume, device, queue.
125export const Sidebar = (props: SidebarProps) => {
126 const { el, playing, progress, act, frame, queue } = props
127 const { Box, Button, Text } = el
128 const width = frame.width
129 return (
130 <Box flexDirection="column" alignItems="center" {...frame}>
131 {Artwork({ ...props, columns: Math.min(32, width) & ~1 })}
132
133 <Box flexDirection="column" alignItems="center" marginTop={1} width={width}>
134 <Text bold wrap="truncate-end">
135 {playing.title}
136 </Text>
137 <Text wrap="truncate-end">{playing.artist}</Text>
138 <Text dimColor italic wrap="truncate-end">
139 {playing.album}
140 </Text>
141 </Box>
142
143 <Box marginTop={1}>
144 <Text dimColor>{clock(progress)} </Text>
145 <Text color="green">{progressBar(progress, playing.durationMs, Math.max(4, width - 12))}</Text>
146 <Text dimColor> {clock(playing.durationMs)}</Text>
147 </Box>
148
149 <Box marginTop={1} gap={1}>
150 <Button key="shuffle" label="⇄" hotkey="s" plain dimColor={!playing.shuffle} onPress={act.shuffle} />
151 <Button key="prev" label="⏮" hotkey="b" onPress={act.prev} />
152 <Button key="toggle" label={playing.isPlaying ? '⏸' : '▶'} hotkey="p" variant="primary" onPress={act.toggle} />
153 <Button key="next" label="⏭" hotkey="n" onPress={act.next} />
154 <Button
155 key="repeat"
156 label={playing.repeat === 'track' ? '↻1' : '↻'}
157 hotkey="r"
158 plain
159 dimColor={playing.repeat === 'off'}
160 onPress={act.repeat}
161 />
162 </Box>
163
164 {Volume({ el, volume: playing.volume, act })}
165 {playing.device ? (
166 <Text dimColor wrap="truncate-end">
167 on {playing.device}
168 </Text>
169 ) : null}
170
171 {queue.length > 0 ? (
172 <Box flexDirection="column" marginTop={1} width={width}>
173 <Text bold dimColor>
174 Up next
175 </Text>
176 {queue.map((line, i) => (
177 <Text dimColor wrap="truncate-end">
178 {i + 1}. {line}
179 </Text>
180 ))}
181 </Box>
182 ) : null}
183
184 <Box marginTop={1}>
185 <Text dimColor>p play · n/b skip · s shuffle · r repeat · m mute</Text>
186 </Box>
187 </Box>
188 )
189}
190
191// Ghostty's background from its config text, when the config sets one.
192export const ghosttyBackground = (config: string) => {
193 const hex = config.match(/^\s*background\s*=\s*#?([0-9a-fA-F]{6})\s*$/m)?.[1]
194 return hex ? `#${hex.toLowerCase()}` : undefined
195}
196
197export const ghosttyConfigs = (home: string) => [
198 `${home}/.config/ghostty/config`,
199 `${home}/Library/Application Support/com.mitchellh.ghostty/config.ghostty`,
200 `${home}/Library/Application Support/com.mitchellh.ghostty/config`,
201]
202
203export const GHOSTTY_DEFAULT_BACKGROUND = '#282c34'
204types/index.d.ts 33 lines1export type NowPlaying = {
2 trackId: string
3 title: string
4 artist: string
5 album: string
6 coverUrl: string | null
7 isPlaying: boolean
8 progressMs: number
9 durationMs: number
10 // When progressMs was read, so the drawing can move the bar between polls.
11 fetchedAt: number
12 device: string
13 volume: number | null
14 shuffle: boolean
15 repeat: 'off' | 'context' | 'track'
16}
17
18// The album art, sampled to a square of RGB pixels (0xRRGGBB), row-major.
19export type Cover = { url: string; size: number; pixels: number[] }
20
21declare module 'claude-code' {
22 interface PluginState {
23 claudify: {
24 now: NowPlaying | null
25 cover: Cover | null
26 queue: string[]
27 isConnected: boolean
28 background: string
29 terminalColor: string | null
30 }
31 }
32}
33