Claude Code mod for Spotify: control the Spotify desktop app from Claude Code on macOS: now playing with progress, play/pause/next/previous, volume, shuffle…

spotify-claude-code-mod is a free, open-source Claude Code mod (plugin) for Spotify on macOS. Control the Spotify desktop app from your terminal without leaving your AI coding session: see what is playing with a progress bar, play, pause, skip, change the volume, toggle shuffle and repeat, play a Spotify link, or search for a song and pick from the results. Uses AppleScript (osascript) and the Spotify Web API. MIT licensed.
Keywords: Claude Code mod, Claude Code mods, Claude Code plugin, Spotify mod for Claude Code, Spotify, Spotify controller, Spotify CLI, music player, now playing, play pause skip, AppleScript, osascript, macOS, Spotify Web API, TypeScript.
Status: pre-release. The code is unit-tested (strict TypeScript, 31 tests) and the status script and album-art pipeline have been run against a live Spotify app, but play, volume, shuffle and search have not yet been tried end to end. Issues and pull requests are welcome.
Not affiliated with or endorsed by Spotify AB. "Spotify" is a trademark of Spotify AB.
/spotify slash commandIt is a safer alternative to Spotify mods and plugins for Claude Code that keep a background daemon and a shared state file. This one has neither:
osascript script is fixed text. The one value that varies (a Spotify URI) is passed as an argument that AppleScript reads as data, never pasted into the script.spotify:track:<22 chars> or an open.spotify.com link) before it gets near a script.i.scdn.co.claude plugin marketplace add gfb-47/spotify-claude-code-mod
claude plugin install spotify-claude-code-mod@spotify-claude-code-mod
macOS will ask once whether Claude Code may control Spotify. Allow it (System Settings > Privacy & Security > Automation).
Playing links and the player controls work with no setup. Search needs a Spotify developer app:
http://127.0.0.1).claude plugin configure spotify-claude-code-mod@spotify-claude-code-mod and enter the client ID and secret.Search uses the client-credentials flow, so there is no user login and no access to your account.
Spotify has been tightening its developer rules: apps in development mode are limited in users and features, and the app owner may need a Premium account. Check https://developer.spotify.com for the current terms before you set this up. Everything except search works without it.
/spotify opens or closes the Spotify pane (Prev, Play/Pause, Next, Vol, Shuffle, Repeat, Search, Refresh). The pane refreshes every 15 seconds./spotify pause, /spotify play, /spotify next, /spotify prev, /spotify shuffle, /spotify repeat/spotify https://open.spotify.com/track/... plays that link./spotify some song name searches and plays the first track. The pane's Search lets you pick from the results instead.This mod adds a $.spotify object that other plugins can call, so a mod with its own Music tab does not need its own Spotify code. Install this mod, then in the other plugin:
const track = await $.spotify.current() // title, artist, album, progress, volume, shuffle, repeat, cover link
await $.spotify.control('next') // resume | pause | next | prev | shuffle | repeat | volume-up | volume-down
const { tracks, problem } = await $.spotify.find('lo-fi beats') // up to four results to pick from
await $.spotify.playLink(tracks[0].uri)
const png = await $.spotify.cover(track.artworkUrl) // small base64 PNG, or null
The types are in types/index.d.ts. Call it inside a try: the engine only allows $.spotify.method(...) spelled out in place, and a plugin that lists this mod under dependencies is not loaded at all when it is missing, so for an optional integration leave dependencies out and catch the error. Search uses the credentials set on this mod.
scripts/verify.sh
Runs tsc (strict, no unused code), the layer check (scripts/check-layers.sh), claude plugin validate and claude plugin test. Set CLAUDE_CODE_TYPES and TSC if they are not found automatically.
| Path | What lives there |
|---|---|
hooks/register.tsx | The only file that uses $ |
hooks/host.ts | The port services use: run a process, fetch a URL, read a file |
hooks/services/music.ts | Use cases: transport, play a link, now playing, album art, search |
hooks/lib/music.ts | Pure rules: URI validation, argv builders, response parsing |
hooks/ui/music.tsx | The pane view |
MIT
hooks/register.tsx 134 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Spotify } from '../types'
5import type { Host } from './host'
6import { errorText } from './lib/format'
7import { parseSpotifyTarget } from './lib/music'
8import { controlSpotify, coverArt, currentTrack, findTracks, playOrSearch, playSpotifyLink, spotifyConfigFrom, type SpotifyConfig } from './services/music'
9import { musicView, type MusicActions } from './ui/music'
10
11const PANE = 'spotify-claude-code-mod'
12const PANE_TITLE = 'Spotify'
13const REFRESH_MS = 15000
14const TRANSPORT_WORDS = ['play', 'pause', 'next', 'prev', 'shuffle', 'repeat']
15
16// The engine reads plugin and key as literals, so they stay spelled out.
17const nowPlaying = atom({ plugin: 'spotify-claude-code-mod', key: 'nowPlaying' } as const, null)
18const note = atom({ plugin: 'spotify-claude-code-mod', key: 'note' } as const, null)
19const cover = atom({ plugin: 'spotify-claude-code-mod', key: 'cover' } as const, null)
20
21// The one place `$` becomes the services' Host.
22function hostOf($: EngineInterface): Host {
23 return {
24 run: (argv, init) => $.process.run(argv, init),
25 fetch: (url, init) => $.http.fetch(url, init),
26 readBase64: async path => (await $.fs.read(path, { as: 'bytes' })).base64,
27 }
28}
29
30async function refresh($: EngineInterface): Promise<void> {
31 const host = hostOf($)
32 const next = await currentTrack(host)
33 await update($, nowPlaying, () => next)
34
35 // The cover is fetched once per picture, not on every refresh.
36 const url = next?.artworkUrl ?? null
37 if (url === ((await read($, cover))?.url ?? null)) return
38 const png = url ? await coverArt(host, url).catch(() => null) : null
39 await update($, cover, () => (url && png ? { url, png } : null))
40}
41
42// What other plugins get as `$.spotify`: the same services the pane uses, so there is one implementation.
43function spotifyOf(host: Host, config: SpotifyConfig | null): Spotify {
44 return {
45 current: () => currentTrack(host),
46 control: action => controlSpotify(host, action),
47 playLink: link => playSpotifyLink(host, link),
48 find: query => findTracks(host, config, query),
49 cover: url => coverArt(host, url),
50 }
51}
52
53// One action: a short note while it runs, the outcome when it is done.
54async function act($: EngineInterface, task: (host: Host) => Promise<string>): Promise<string> {
55 await update($, note, () => 'Spotify…')
56 const text = await task(hostOf($)).catch(error => `Spotify did not answer: ${errorText(error)}`)
57 await update($, note, () => text)
58 await refresh($)
59
60 return text
61}
62
63async function isShown($: EngineInterface): Promise<boolean> {
64 return (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
65}
66
67function actionsOf($: EngineInterface, config: SpotifyConfig | null): MusicActions {
68 return {
69 refresh: () => refresh($),
70 transport: async action => void (await act($, host => controlSpotify(host, action))),
71 search: async () => {
72 const query = (await $.ui.ask('What should Spotify play? Pick one, or type a search or a Spotify link under Other.', { options: ['lo-fi beats', 'deep focus'], header: 'Spotify' })).trim()
73 if (!query) return
74 if (parseSpotifyTarget(query)) return void (await act($, host => playSpotifyLink(host, query)))
75 await act($, async host => {
76 const { tracks, problem } = await findTracks(host, config, query)
77 if (!tracks.length) return problem ?? 'No track found.'
78 const options = tracks.map(track => track.label)
79 const choice = options.length > 1 ? (await $.ui.ask('Which one?', { options, header: 'Spotify' })).trim() : (options[0] ?? '')
80 const picked = tracks.find(track => track.label === choice)
81
82 return picked ? playSpotifyLink(host, picked.uri) : 'Nothing was played.'
83 })
84 },
85 }
86}
87
88export const register: Register = (on, options) => {
89 const config = spotifyConfigFrom(options)
90
91 on('engine.create', async (_, e, next) => {
92 const built = await next(e)
93 // The engine only lets `built` be called in place, so the host is spelled out here rather than via hostOf.
94 const host: Host = {
95 run: (argv, init) => built.process.run(argv, init),
96 fetch: (url, init) => built.http.fetch(url, init),
97 readBase64: async path => (await built.fs.read(path, { as: 'bytes' })).base64,
98 }
99
100 return { ...built, spotify: spotifyOf(host, config) }
101 })
102
103 on('session.start', async ($, e, next) => {
104 await $.command.register({ name: 'spotify', description: 'Open the Spotify pane, or: spotify <play|pause|next|prev|shuffle|repeat|link|search words>' })
105 void refresh($)
106 $.clock.every(REFRESH_MS, () => void refresh($))
107
108 return next(e)
109 })
110
111 on('command.run', { command: 'spotify' }, async ($, e) => {
112 const arg = e.args.trim()
113 if (!arg) {
114 if (await isShown($)) {
115 await $.ui.close({ id: PANE })
116
117 return { text: 'Spotify pane closed.' }
118 }
119 await $.ui.open({ id: PANE, title: PANE_TITLE, focus: true })
120
121 return { text: 'Spotify pane opened.' }
122 }
123 if (TRANSPORT_WORDS.includes(arg)) return { text: await act($, host => controlSpotify(host, arg === 'play' ? 'resume' : arg)) }
124
125 return { text: await act($, host => playOrSearch(host, config, arg)) }
126 })
127
128 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
129 const [track, art, text] = await Promise.all([read($, nowPlaying), read($, cover), read($, note)])
130
131 return musicView($.ui.resolve(e), track, art?.png ?? null, text, actionsOf($, config), e.props.bodyColumns)
132 })
133}
134hooks/host.ts 10 lines1import type { HttpInit, HttpResponse, ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3// What the services need from the engine. The engine only lets `$` travel within register.tsx,
4// so register.tsx builds one of these from `$` and the services stay plain, testable code.
5export type Host = {
6 run: (argv: string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
7 fetch: (url: string, init?: HttpInit) => Promise<HttpResponse>
8 readBase64: (path: string) => Promise<string>
9}
10hooks/lib/format.ts 16 lines1export function clip(text: string, width: number): string {
2 return text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text
3}
4
5export function errorText(error: unknown): string {
6 return error instanceof Error ? error.message : String(error)
7}
8
9export function parseJson(text: string): unknown {
10 try {
11 return JSON.parse(text)
12 } catch {
13 return text
14 }
15}
16hooks/lib/music.ts 203 lines1import type { SpotifyFoundTrack, SpotifyNowPlaying } from '../../types'
2
3// Spotify's catalogue is outside this mod, so titles and artists are shown as text only.
4function text(value: unknown, fallback: string): string {
5 return typeof value === 'string' && value.trim() ? value.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 120) : fallback
6}
7
8// Driving the Spotify desktop app with osascript. Nothing a person, a file or a web response says is
9// ever pasted into a script: the scripts below are fixed text, and the one thing that varies (a Spotify
10// URI) travels as an argument that AppleScript reads as data.
11export type SpotifyTarget = { kind: string; uri: string }
12
13const KINDS = 'track|album|playlist|artist|episode'
14const URI = new RegExp(`^spotify:(${KINDS}):([A-Za-z0-9]{22})$`)
15const LINK = new RegExp(`^https://open\\.spotify\\.com/(?:intl-[a-z]{2}/)?(${KINDS})/([A-Za-z0-9]{22})(?:[?#].*)?$`)
16
17// Accepts a Spotify URI or an open.spotify.com link and nothing else; the id is rebuilt from the match.
18export function parseSpotifyTarget(raw: string): SpotifyTarget | null {
19 const match = URI.exec(raw.trim()) ?? LINK.exec(raw.trim())
20 const kind = match?.[1]
21 const id = match?.[2]
22
23 return kind && id ? { kind, uri: `spotify:${kind}:${id}` } : null
24}
25
26// Each action is a few fixed lines that run inside `tell application "Spotify"`.
27const TRANSPORT: Record<string, string[]> = {
28 resume: ['play'],
29 pause: ['pause'],
30 next: ['next track'],
31 prev: ['previous track'],
32 shuffle: ['set shuffling to not shuffling'],
33 repeat: ['set repeating to not repeating'],
34 'volume-up': ['set level to (sound volume) + 10', 'if level > 100 then set level to 100', 'set sound volume to level'],
35 'volume-down': ['set level to (sound volume) - 10', 'if level < 0 then set level to 0', 'set sound volume to level'],
36}
37
38function script(lines: string[], ...args: string[]): string[] {
39 return ['osascript', ...lines.flatMap(line => ['-e', line]), ...args]
40}
41
42// Prints "closed" instead of launching Spotify when it is not running.
43export function transportArgv(action: string): string[] | null {
44 if (!Object.hasOwn(TRANSPORT, action)) return null
45
46 return script([
47 'if application "Spotify" is not running then return "closed"',
48 'tell application "Spotify"',
49 ...(TRANSPORT[action] ?? []),
50 'end tell',
51 'return "ok"',
52 ])
53}
54
55export function playArgv(target: SpotifyTarget): string[] {
56 return script(
57 [
58 'on run argv',
59 'set trackUri to item 1 of argv',
60 'if application "Spotify" is not running then return "closed"',
61 'tell application "Spotify" to play track trackUri',
62 'return "ok"',
63 'end run',
64 ],
65 target.uri,
66 )
67}
68
69// One line of fields split by the ASCII unit separator, which no title or artist can contain.
70const FIELDS = 11
71const SEPARATOR = '\u001f'
72
73export function statusArgv(): string[] {
74 return script([
75 'if application "Spotify" is not running then return ""',
76 'tell application "Spotify"',
77 'if player state is stopped then return ""',
78 'set sep to character id 31',
79 'set song to current track',
80 'set art to ""',
81 'try',
82 'set art to artwork url of song',
83 'end try',
84 'return (name of song) & sep & (artist of song) & sep & (album of song) & sep & (player state as text) & sep & (((player position) as integer) as text) & sep & (((duration of song) div 1000) as text) & sep & (sound volume as text) & sep & (shuffling as text) & sep & (repeating as text) & sep & art & sep & (spotify url of song)',
85 'end tell',
86 ])
87}
88
89const ARTWORK = /^https:\/\/i\.scdn\.co\/image\/[a-f0-9]{40}$/
90
91function whole(value: string | undefined, max: number): number {
92 return value && /^\d{1,7}$/.test(value.trim()) ? Math.min(Number(value), max) : 0
93}
94
95// The page for a track, rebuilt from the validated id rather than echoed back.
96export function webLink(target: SpotifyTarget): string {
97 return `https://open.spotify.com/${target.kind}/${target.uri.slice(target.uri.lastIndexOf(':') + 1)}`
98}
99
100export function parseTrack(stdout: string): SpotifyNowPlaying | null {
101 const fields = stdout.trim().split(SEPARATOR)
102 if (fields.length !== FIELDS) return null
103 const title = text(fields[0], '')
104 if (!title) return null
105 const target = parseSpotifyTarget(fields[10] ?? '')
106 const artwork = (fields[9] ?? '').trim()
107
108 return {
109 title,
110 artist: text(fields[1], 'Spotify'),
111 album: text(fields[2], ''),
112 isPlaying: fields[3]?.trim() === 'playing',
113 positionSec: whole(fields[4], 359999),
114 durationSec: whole(fields[5], 359999),
115 volume: whole(fields[6], 100),
116 isShuffling: fields[7]?.trim() === 'true',
117 isRepeating: fields[8]?.trim() === 'true',
118 artworkUrl: ARTWORK.test(artwork) ? artwork : null,
119 url: target?.kind === 'track' ? webLink(target) : null,
120 }
121}
122
123// Album art: fetched with fixed commands into a private temp folder, shrunk to a small PNG, then removed.
124// Only a validated Spotify image link and a folder the system just made ever reach these arguments.
125const TEMP_DIR = /^\/[\w./-]*\/tmp\.[A-Za-z0-9]{6,12}$/
126
127// Only kitty and Ghostty draw pictures, so nothing is downloaded for any other terminal.
128export function drawsImages(termProgram: string, term: string): boolean {
129 return /ghostty/i.test(termProgram) || /kitty|ghostty/i.test(term)
130}
131
132export function mktempArgv(): string[] {
133 return ['mktemp', '-d']
134}
135
136export function tempDirOf(stdout: string): string | null {
137 const dir = stdout.trim()
138
139 return TEMP_DIR.test(dir) && !dir.includes('..') ? dir : null
140}
141
142export function fetchArtArgv(url: string, dir: string): string[] | null {
143 return ARTWORK.test(url) ? ['curl', '--fail', '--silent', '--max-time', '10', '--max-filesize', '2000000', '--output', `${dir}/cover.jpg`, url] : null
144}
145
146export function shrinkArtArgv(dir: string): string[] {
147 return ['sips', '-s', 'format', 'png', '-Z', '200', `${dir}/cover.jpg`, '--out', `${dir}/cover.png`]
148}
149
150export function artPath(dir: string): string {
151 return `${dir}/cover.png`
152}
153
154export function cleanupArgv(dir: string): string[] {
155 return ['rm', '-r', dir]
156}
157
158export function searchQuery(raw: string): string {
159 return raw.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 100)
160}
161
162export function searchUrl(query: string): string {
163 return `https://api.spotify.com/v1/search?type=track&limit=4&q=${encodeURIComponent(query)}`
164}
165
166export function accessTokenOf(body: unknown): string | null {
167 const token = (body as { access_token?: unknown } | null)?.access_token
168
169 return typeof token === 'string' && /^[\w.~+/-]+=*$/.test(token) ? token : null
170}
171
172type SearchItem = { uri?: unknown; name?: unknown; artists?: { name?: unknown }[] }
173
174// The hits worth offering: each URI is checked like any other link, so a bad response cannot reach a script.
175export function tracksOf(body: unknown): SpotifyFoundTrack[] {
176 const items = (body as { tracks?: { items?: unknown } } | null)?.tracks?.items
177 if (!Array.isArray(items)) return []
178
179 return items.slice(0, 4).flatMap((item: SearchItem) => {
180 const target = typeof item?.uri === 'string' ? parseSpotifyTarget(item.uri) : null
181 if (target?.kind !== 'track') return []
182 const artists = Array.isArray(item.artists) ? item.artists.map(artist => text(artist?.name, '')).filter(Boolean) : []
183
184 return [{ uri: target.uri, label: text(artists.length ? `${text(item.name, 'Track')} - ${artists.join(', ')}` : item.name, 'Track').slice(0, 70) }]
185 })
186}
187
188const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
189
190// HTTP Basic credentials for Spotify's token endpoint. Ids and secrets are ASCII, so bytes are char codes.
191export function basicAuth(id: string, secret: string): string {
192 const bytes = Array.from(`${id}:${secret}`, char => char.charCodeAt(0) & 255)
193 let out = ''
194 for (let at = 0; at < bytes.length; at += 3) {
195 const n = ((bytes[at] ?? 0) << 16) | ((bytes[at + 1] ?? 0) << 8) | (bytes[at + 2] ?? 0)
196 out += BASE64.charAt((n >> 18) & 63) + BASE64.charAt((n >> 12) & 63)
197 out += at + 1 < bytes.length ? BASE64.charAt((n >> 6) & 63) : '='
198 out += at + 2 < bytes.length ? BASE64.charAt(n & 63) : '='
199 }
200
201 return `Basic ${out}`
202}
203hooks/services/music.ts 120 lines1import type { SpotifyNowPlaying, SpotifySearch } from '../../types'
2import type { Host } from '../host'
3import { parseJson } from '../lib/format'
4import {
5 accessTokenOf,
6 artPath,
7 basicAuth,
8 cleanupArgv,
9 drawsImages,
10 fetchArtArgv,
11 mktempArgv,
12 parseSpotifyTarget,
13 parseTrack,
14 playArgv,
15 searchQuery,
16 searchUrl,
17 shrinkArtArgv,
18 statusArgv,
19 tempDirOf,
20 tracksOf,
21 transportArgv,
22} from '../lib/music'
23
24export type SpotifyConfig = { id: string; secret: string }
25
26const TOKEN_URL = 'https://accounts.spotify.com/api/token'
27const COMMAND_MS = 10000
28
29// The secret only ever goes to Spotify's own https endpoints, and never into the store, a file or a note.
30export function spotifyConfigFrom(options: Record<string, unknown>): SpotifyConfig | null {
31 const id = typeof options.spotify_client_id === 'string' ? options.spotify_client_id.trim() : ''
32 const secret = typeof options.spotify_client_secret === 'string' ? options.spotify_client_secret.trim() : ''
33
34 return id && secret ? { id, secret } : null
35}
36
37async function command(host: Host, argv: string[]): Promise<string | null> {
38 const result = await host.run(argv, { timeoutMs: COMMAND_MS }).catch(() => null)
39
40 return result && result.exitCode === 0 ? result.stdout.trim() : null
41}
42
43function outcome(answer: string | null, done: string): string {
44 if (answer === null) return 'Spotify did not answer. Is the desktop app installed, and is Claude Code allowed to control it?'
45
46 return answer === 'closed' ? 'Spotify is not running. Open it first.' : done
47}
48
49export async function controlSpotify(host: Host, action: string): Promise<string> {
50 const argv = transportArgv(action)
51 if (!argv) return `Unknown Spotify action: ${action}`
52
53 return outcome(await command(host, argv), 'Done.')
54}
55
56export async function playSpotifyLink(host: Host, raw: string): Promise<string> {
57 const target = parseSpotifyTarget(raw)
58 if (!target) return 'That is not a Spotify link or URI.'
59
60 return outcome(await command(host, playArgv(target)), `Playing ${target.kind}.`)
61}
62
63export async function currentTrack(host: Host): Promise<SpotifyNowPlaying | null> {
64 const answer = await command(host, statusArgv())
65
66 return answer ? parseTrack(answer) : null
67}
68
69// The cover as a small PNG (base64), or null when any step fails. The temp folder is always removed.
70export async function coverArt(host: Host, url: string): Promise<string | null> {
71 const [program, term] = await Promise.all([command(host, ['printenv', 'TERM_PROGRAM']), command(host, ['printenv', 'TERM'])])
72 if (!drawsImages(program ?? '', term ?? '')) return null
73 const dir = tempDirOf((await command(host, mktempArgv())) ?? '')
74 const download = dir ? fetchArtArgv(url, dir) : null
75 if (!dir || !download) return null
76
77 try {
78 if ((await command(host, download)) === null || (await command(host, shrinkArtArgv(dir))) === null) return null
79
80 return await host.readBase64(artPath(dir))
81 } catch {
82 return null
83 } finally {
84 await command(host, cleanupArgv(dir))
85 }
86}
87
88// Looks the query up with Spotify's client-credentials flow (no user login).
89export async function findTracks(host: Host, config: SpotifyConfig | null, raw: string): Promise<SpotifySearch> {
90 const query = searchQuery(raw)
91 if (!query) return { tracks: [], problem: 'Type something to search for.' }
92 if (!config) return { tracks: [], problem: 'Add a Spotify client id and secret in the plugin settings to search.' }
93
94 const token = await host.fetch(TOKEN_URL, {
95 method: 'POST',
96 headers: { Authorization: basicAuth(config.id, config.secret), 'Content-Type': 'application/x-www-form-urlencoded' },
97 body: 'grant_type=client_credentials',
98 })
99 const accessToken = token.ok ? accessTokenOf(parseJson(token.text)) : null
100 if (!accessToken) return { tracks: [], problem: `Spotify refused the client id and secret (${token.status}).` }
101
102 const found = await host.fetch(searchUrl(query), { headers: { Authorization: `Bearer ${accessToken}` } })
103 if (!found.ok) return { tracks: [], problem: `Spotify search failed (${found.status}).` }
104 const tracks = tracksOf(parseJson(found.text))
105
106 return { tracks, problem: tracks.length ? null : `No track found for "${query}".` }
107}
108
109// The slash command's search: plays the first hit.
110export async function searchAndPlay(host: Host, config: SpotifyConfig | null, raw: string): Promise<string> {
111 const { tracks, problem } = await findTracks(host, config, raw)
112
113 return tracks[0] ? playSpotifyLink(host, tracks[0].uri) : (problem ?? 'No track found.')
114}
115
116// What the Search box does: a pasted Spotify link plays as is, anything else is searched for.
117export async function playOrSearch(host: Host, config: SpotifyConfig | null, raw: string): Promise<string> {
118 return parseSpotifyTarget(raw) ? playSpotifyLink(host, raw) : searchAndPlay(host, config, raw)
119}
120hooks/ui/music.tsx 83 lines1import type { BoxProps, ButtonProps, ElementConstructor, ImageProps, LinkProps, TextProps } from 'claude-code'
2
3import type { SpotifyNowPlaying } from '../../types'
4import { clip } from '../lib/format'
5
6// The elements the view draws with, from `$.ui.resolve(e)`. Views never see `$`.
7// Image only exists on the terminal surface, and only draws in kitty and Ghostty.
8export type Kit = {
9 Box: ElementConstructor<BoxProps>
10 Text: ElementConstructor<TextProps>
11 Button: ElementConstructor<ButtonProps>
12 Link: ElementConstructor<LinkProps>
13 Image?: ElementConstructor<ImageProps>
14}
15
16export type MusicActions = {
17 refresh: () => Promise<void>
18 transport: (action: string) => Promise<void>
19 search: () => Promise<void>
20}
21
22const COVER_COLUMNS = 14
23const COVER_ROWS = 7
24
25function clock(seconds: number): string {
26 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
27}
28
29function progress(track: SpotifyNowPlaying, columns: number): string {
30 const done = track.durationSec > 0 ? Math.min(columns, Math.round((track.positionSec / track.durationSec) * columns)) : 0
31
32 return `${clock(track.positionSec)} ${'█'.repeat(done)}${'░'.repeat(columns - done)} ${clock(track.durationSec)}`
33}
34
35export function musicView(ui: Kit, track: SpotifyNowPlaying | null, cover: string | null, note: string | null, actions: MusicActions, width: number) {
36 const { Box, Text, Button, Link, Image } = ui
37 const control = (key: string, label: string, action: string, hotkey: string) => (
38 <Button key={`music-${key}`} plain hotkey={hotkey} label={label} onPress={() => actions.transport(action)} />
39 )
40 const textWidth = width - (cover ? COVER_COLUMNS + 2 : 0)
41
42 return (
43 <Box flexDirection="column" gap={1}>
44 <Box justifyContent="space-between">
45 <Text bold>Spotify</Text>
46 <Button key="music-refresh" plain dimColor hotkey="r" label="Refresh" onPress={actions.refresh} />
47 </Box>
48 {track ? (
49 <Box columnGap={2}>
50 {cover && Image && <Image key="music-cover" source={{ png: cover }} columns={COVER_COLUMNS} rows={COVER_ROWS} alt=" " />}
51 <Box flexDirection="column">
52 <Text color={track.isPlaying ? 'green' : undefined}>
53 {track.isPlaying ? '▶' : '❚❚'} {clip(track.title, textWidth - 3)}
54 </Text>
55 <Text dimColor>{clip(track.artist, textWidth)}</Text>
56 {track.album && <Text dimColor>{clip(track.album, textWidth)}</Text>}
57 <Text>{progress(track, Math.max(8, Math.min(30, textWidth - 12)))}</Text>
58 {track.url && <Link href={track.url} label="Open in Spotify" />}
59 </Box>
60 </Box>
61 ) : (
62 <Text dimColor>Nothing playing. Open Spotify, or search for something to start.</Text>
63 )}
64 <Box columnGap={2}>
65 {control('prev', '⏮ Prev', 'prev', 'b')}
66 {control('toggle', track?.isPlaying ? '❚❚ Pause' : '▶ Play', track?.isPlaying ? 'pause' : 'resume', 'p')}
67 {control('next', 'Next ⏭', 'next', 'n')}
68 </Box>
69 {track && (
70 <Box columnGap={2}>
71 {control('vol-down', '− Vol', 'volume-down', 'd')}
72 <Text dimColor>{`${track.volume}%`}</Text>
73 {control('vol-up', 'Vol +', 'volume-up', 'u')}
74 {control('shuffle', `⇄ Shuffle ${track.isShuffling ? 'on' : 'off'}`, 'shuffle', 'z')}
75 {control('repeat', `↻ Repeat ${track.isRepeating ? 'on' : 'off'}`, 'repeat', 'e')}
76 </Box>
77 )}
78 <Button key="music-search" plain hotkey="s" label="Search…" onPress={actions.search} />
79 {note && <Text dimColor>{clip(note, width * 2)}</Text>}
80 </Box>
81 )
82}
83types/index.d.ts 46 lines1export type SpotifyNowPlaying = {
2 title: string
3 artist: string
4 album: string
5 isPlaying: boolean
6 positionSec: number
7 durationSec: number
8 volume: number
9 isShuffling: boolean
10 isRepeating: boolean
11 artworkUrl: string | null
12 url: string | null
13}
14
15export type SpotifyFoundTrack = { uri: string; label: string }
16
17// A search never throws: `problem` says why there is nothing to pick from.
18export type SpotifySearch = { tracks: SpotifyFoundTrack[]; problem: string | null }
19
20export type SpotifyAction = 'resume' | 'pause' | 'next' | 'prev' | 'shuffle' | 'repeat' | 'volume-up' | 'volume-down'
21
22// What `$.spotify` gives another plugin. Every call answers with plain data or a sentence to show, and
23// none of them launches Spotify: a closed app is reported, not started.
24export type Spotify = {
25 current: () => Promise<SpotifyNowPlaying | null>
26 control: (action: SpotifyAction) => Promise<string>
27 playLink: (link: string) => Promise<string>
28 find: (query: string) => Promise<SpotifySearch>
29 // The cover as a small base64 PNG, or null (also null in terminals that cannot draw it).
30 cover: (url: string) => Promise<string | null>
31}
32
33declare module 'claude-code' {
34 interface EngineInterface {
35 spotify: Spotify
36 }
37
38 interface PluginState {
39 'spotify-claude-code-mod': {
40 nowPlaying: SpotifyNowPlaying | null
41 note: string | null
42 cover: { url: string; png: string } | null
43 }
44 }
45}
46