Control YouTube Music, Spotify or anything else playing from Claude Code: a now-playing band, a /music pane with cover art and progress, /play /pause /next…

Control your music from Claude Code: YouTube Music, Spotify, a browser tab, anything that shows up in the Windows media flyout.
♪ Midnight City — M83 · Chrome ⏮ ⏸ ⏭ 🔊 − 40% + ✕
♪ Title — Artist status line entry takes its place); /dj show brings it back. It stays hidden across sessions until you do./music pane: the cover art, title, artist and album, a progress bar that moves while the track plays (1:10 ━━━━━━●────────── 4:03), and bigger controls with hotkeys while the pane has focus: k play/pause, p / n previous / next, m mute, d / u volume down / up, l like (Spotify). The cover shows as a picture in the desktop app and VS Code; in the terminal it does in kitty and Ghostty, and is left out elsewhere.| Command | Does |
|---|---|
/dj | toggle play/pause |
/dj play, /dj pause, /dj next, /dj prev | what it says |
/play, /pause, /next, /prev | short forms |
/vol | show the volume |
/vol 40, /vol +5, /vol -5, /vol up, /vol down | set or change the volume |
/vol mute, /vol unmute (or /dj mute, /dj vol 40) | mute and unmute |
/dj hide, /dj show | hide or show the band |
/music | open the Now playing pane |
now_playing, music_control and music_volume tools.Play, pause, skip and volume already work for the Spotify app without setup. Sign in to Spotify for more:
| Command | Does |
|---|---|
/spotify <song> or /play <song> | find a song and play it |
/spotify album …, /spotify artist …, /spotify playlist … | play a whole album, artist or playlist |
/spotify <spotify: URI or open.spotify.com link> | play exactly that |
/spotify queue <song> | add a song to the queue |
/spotify like (or ♥ on the band) | save the playing song to Liked Songs |
/spotify devices, /spotify device <name> | list devices, move playback to one |
/spotify, /spotify login, /spotify logout | status, sign in, sign out |
Claude also gets spotify_search, spotify_play, spotify_queue and spotify_like, so "put on some lofi for focus" or "queue something like this" work.
When Spotify isn't open anywhere, the mod starts the Spotify app on this PC and plays there.
Spotify only gives API access to apps you register yourself, and the account has to be Premium (Spotify's rule since February 2026; free accounts can search but not play or queue).
http://127.0.0.1:43117/callback exactly.spotifyClientId setting./spotify login and approve in the browser tab that opens.The sign-in uses PKCE, so there's no client secret. Tokens are kept in the mod's own store on this machine and refreshed automatically. /spotify logout forgets them; remove the app at spotify.com/account/apps to revoke access fully.
The mod runs one hidden Windows PowerShell watcher per session that reads the system media session (SMTC), the same one behind the media keys and the volume flyout. No API keys or sign-in needed, and no browser extension. When several apps play at once it prefers the one with an artist (music) and sticks with it.
Volume is the system output volume, the one your volume keys and the taskbar flyout change. Windows doesn't expose a per-app volume for another app's audio session on current Windows 11 builds, so the mod can't change only the browser's or player's slider in the volume mixer.
The pane's cover comes from the same media session (the artwork the app hands Windows; for a YouTube video that's its thumbnail, cropped to a square). Covers are kept as small temporary files and replaced when the track changes.
The watcher exits with the session, or by itself two minutes after the session stops sending a heartbeat.
In the config menu (or pluginConfigs["claude-dj"].options in settings.json):
| Option | Default |
|---|---|
showBand | true; off shows a ♪ Title — Artist status line entry instead |
volumeStep | 10; how far the band's − / + and /vol up / down move the volume |
spotifyClientId | (empty); your Spotify app's Client ID, which turns on /spotify and the Spotify tools |
musicTools | true; off hides the music tools from Claude |
nowplaying-cli) and Linux (playerctl)hooks/register.tsx 864 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Player, SpotifyStatus } from '../types'
5import {
6 ACTIONS,
7 PAST,
8 actionOf,
9 appName,
10 coverSvg,
11 describe,
12 describeVolume,
13 formatTime,
14 isPlaying,
15 parseLine,
16 positionNow,
17 progressBar,
18 splitLines,
19 trackLine,
20 volumeCommand,
21 volumeOf,
22 volumePast,
23} from './dj'
24import type { Action, VolumeChange } from './dj'
25import { HEARTBEAT_MS, WINDOWS_WATCHER, slashes, windowsWatcherArgv } from './host'
26import {
27 ACCOUNTS,
28 API,
29 CALLBACK_LISTENER,
30 KINDS,
31 REDIRECT_URI,
32 asAccount,
33 authorizeUrl,
34 codeExchangeBody,
35 currentItem,
36 errorText,
37 isNoActiveDevice,
38 itemLine,
39 kindOfUri,
40 listenerArgv,
41 openArgv,
42 parseAccount,
43 parseCallback,
44 parseDevices,
45 parseTokens,
46 pickDevice,
47 pkce,
48 randomText,
49 refreshBody,
50 searchItems,
51 spotifyCommandOf,
52 spotifyUri,
53} from './spotify'
54import type { Device, Item, Kind } from './spotify'
55
56const PLUGIN = 'claude-dj'
57const NOW_PLAYING = `mcp__${PLUGIN}__now_playing`
58const CONTROL = `mcp__${PLUGIN}__music_control`
59const VOLUME = `mcp__${PLUGIN}__music_volume`
60const SPOTIFY_SEARCH = `mcp__${PLUGIN}__spotify_search`
61const SPOTIFY_PLAY = `mcp__${PLUGIN}__spotify_play`
62const SPOTIFY_QUEUE = `mcp__${PLUGIN}__spotify_queue`
63const SPOTIFY_LIKE = `mcp__${PLUGIN}__spotify_like`
64const VOLUME_HINT = '[0-100 | +10 | -10 | up | down | mute | unmute]'
65const SPOTIFY_HINT = '[song | album … | playlist … | queue … | like | devices | login | logout]'
66const RESTARTS = 3
67/** Where `$.store` keeps the Spotify sign-in (tokens, account name and plan). */
68const SPOTIFY_STORE = 'spotify'
69const FORM = { 'content-type': 'application/x-www-form-urlencoded' }
70const SETUP = [
71 'Spotify is not set up yet. Once, with a Spotify Premium account:',
72 '1. Create an app at https://developer.spotify.com/dashboard (pick Web API).',
73 `2. Add this Redirect URI to it: ${REDIRECT_URI}`,
74 "3. Put the app's Client ID in claude-dj's spotifyClientId setting.",
75 '4. Run /spotify login.',
76].join('\n')
77
78const playerAtom = atom({ plugin: 'claude-dj', key: 'player' } as const, { kind: 'starting' })
79const spotifyAtom = atom({ plugin: 'claude-dj', key: 'spotify' } as const, null)
80const hiddenAtom = atom({ plugin: 'claude-dj', key: 'isHidden' } as const, false)
81const tickAtom = atom({ plugin: 'claude-dj', key: 'tick' } as const, 0)
82/** The /music pane's id. */
83const PANE = 'claude-dj'
84/** Where `$.store` remembers that the band was hidden, so it stays hidden in the next session. */
85const HIDDEN_STORE = 'bandHidden'
86const DJ_HINT = '[play | pause | next | prev | vol … | hide | show]'
87
88/** The slash commands; `/dj` and `/vol` take theirs as the argument. */
89const COMMANDS: { name: string; action?: Action; isVolume?: true; description: string }[] = [
90 { name: 'dj', description: 'Control the music: /dj [play | pause | next | prev | vol … | hide | show], no argument toggles' },
91 { name: 'play', action: 'play', description: 'Play the music; /play <song> finds and plays it on Spotify' },
92 { name: 'pause', action: 'pause', description: 'Pause the music' },
93 { name: 'next', action: 'next', description: 'Skip to the next track' },
94 { name: 'prev', action: 'previous', description: 'Go back to the previous track' },
95 { name: 'vol', isVolume: true, description: `Set the volume: /vol ${VOLUME_HINT}` },
96]
97
98type SpotifyAnswer = { status: number; text: string } | { error: string }
99
100/** What this load of the module holds; a reload starts it over (and a new watcher). */
101const live: {
102 showBand: boolean
103 /** How far up and down move the volume, in percent. */
104 step: number
105 /** The watcher's work folder, fresh per start, so no watcher reads another's commands. */
106 workDir: string | undefined
107 sent: number
108 /** Wakes those waiting for the watcher's next state. */
109 waiters: ((player: Player) => void)[]
110 /** The Spotify app's Client ID from the settings; empty when Spotify is not set up. */
111 clientId: string
112 /** The 1 s timer that moves the pane's progress bar; runs only while the pane is open. */
113 ticker: Timer | undefined
114 /** The cover JPEG last read for the pane, so a tick does not read the file again. */
115 cover: { path: string; base64: string } | undefined
116} = {
117 showBand: true,
118 step: 10,
119 workDir: undefined,
120 sent: 0,
121 waiters: [],
122 clientId: '',
123 ticker: undefined,
124 cover: undefined,
125}
126
127/** Without the band (turned off, or hidden), a status line entry says what plays. */
128async function refreshStatus($: EngineInterface, player: Player): Promise<void> {
129 const isBandOff = !live.showBand || (await read($, hiddenAtom))
130 const track = player.kind === 'ready' ? player.track : null
131 $.ui.status(isBandOff && track !== null && isPlaying(track) ? `♪ ${trackLine(track, 40)}` : undefined)
132}
133
134async function setPlayer($: EngineInterface, player: Player): Promise<void> {
135 await update($, playerAtom, () => player)
136 await refreshStatus($, player)
137}
138
139async function setHidden($: EngineInterface, isHidden: boolean): Promise<string> {
140 await update($, hiddenAtom, () => isHidden)
141 await $.store.set(HIDDEN_STORE, isHidden)
142 await refreshStatus($, await read($, playerAtom))
143 if (!live.showBand) return 'The band is turned off in the settings (showBand); turn that on to see it.'
144
145 return isHidden ? 'Band hidden. /dj show brings it back.' : 'Band shown.'
146}
147
148async function hideFromBand($: EngineInterface): Promise<void> {
149 $.ui.toast(await setHidden($, true))
150}
151
152async function onLine($: EngineInterface, line: string): Promise<void> {
153 const said = parseLine(line)
154 if (said === undefined) return
155 if ('error' in said) {
156 $.ui.log(`${PLUGIN}: ${said.error}`, { to: 'debug' })
157 return
158 }
159 const player: Player = { kind: 'ready', track: said.track, volume: said.volume }
160 await setPlayer($, player)
161 const woken = live.waiters
162 live.waiters = []
163 for (const wake of woken) wake(player)
164}
165
166/** Reads the watcher's lines for as long as it runs; restarts it when it fails. */
167async function follow($: EngineInterface, dir: string, script: string, attempt: number): Promise<void> {
168 let buffer = ''
169 let errors = ''
170 try {
171 for await (const { stream, text } of $.process.spawn({ argv: windowsWatcherArgv(script, dir) })) {
172 if (stream === 'stderr') {
173 errors = `${errors}${text}`.slice(-400)
174 continue
175 }
176 const { lines, rest } = splitLines(`${buffer}${text}`)
177 buffer = rest
178 for (const one of lines) await onLine($, one)
179 }
180 } catch (error) {
181 errors = String(error)
182 }
183 // The watcher ended: a reload killed it (and this module with it), or it failed.
184 try {
185 if (live.workDir !== dir) return
186 live.workDir = undefined
187 const reason = errors.trim() === '' ? 'the media watcher stopped' : (errors.trim().split(/\r?\n/)[0] ?? '')
188 $.ui.log(`${PLUGIN}: watcher ended: ${errors.trim()}`, { to: 'debug' })
189 if (attempt < RESTARTS) {
190 $.clock.after(5_000 * (attempt + 1), () => void startWatcher($, attempt + 1).catch(() => undefined))
191 } else {
192 await setPlayer($, { kind: 'unavailable', reason })
193 }
194 } catch {
195 // The module is gone; nothing to tell.
196 }
197}
198
199async function startWatcher($: EngineInterface, attempt: number): Promise<void> {
200 const temp = slashes((await $.env.get('TEMP')) ?? (await $.env.get('TMP')) ?? '.').replace(/\/$/, '')
201 const dir = `${temp}/${PLUGIN}/${(await $.clock.now()).toString(36)}`
202 const script = `${dir}/watch.ps1`
203 await $.fs.write(script, WINDOWS_WATCHER)
204 await $.fs.write(`${dir}/heartbeat`, 'alive')
205 live.workDir = dir
206 void follow($, dir, script, attempt)
207}
208
209/** Hands the watcher one command line; false when there is no watcher to take it. */
210async function send($: EngineInterface, command: string): Promise<boolean> {
211 const dir = live.workDir
212 if (dir === undefined) return false
213 live.sent += 1
214 const name = `${(await $.clock.now()).toString().padStart(15, '0')}-${String(live.sent).padStart(4, '0')}`
215 await $.fs.write(`${dir}/commands/${name}.cmd`, command)
216
217 return true
218}
219
220/**
221 * Sends a command line and waits, briefly, for the state it leads to; says
222 * what was done, then the track, or the volume after a volume change.
223 */
224async function control($: EngineInterface, command: string, done: string, isVolume: boolean): Promise<string> {
225 const player = await read($, playerAtom)
226 if (player.kind === 'unavailable') return `Music control is unavailable: ${player.reason}`
227 const changed = new Promise<Player>(resolve => live.waiters.push(resolve))
228 if (!(await send($, command))) return 'The music watcher is still starting; try again in a moment.'
229 // A play that was already playing changes nothing: then what was, is.
230 const now = await Promise.race([changed, $.clock.sleep(1_500).then(() => player)])
231 if (now.kind !== 'ready') return `${done}.`
232
233 return `${done}. ${isVolume ? describeVolume(now.volume) : describe(now.track)}`
234}
235
236async function transport($: EngineInterface, action: Action): Promise<string> {
237 return control($, action, PAST[action], false)
238}
239
240async function volume($: EngineInterface, change: VolumeChange | 'show'): Promise<string> {
241 if (change !== 'show') return control($, volumeCommand(change), volumePast(change), true)
242 const player = await read($, playerAtom)
243 if (player.kind === 'unavailable') return `Music control is unavailable: ${player.reason}`
244
245 return player.kind === 'ready' ? describeVolume(player.volume) : 'The music watcher is still starting.'
246}
247
248/** `/dj vol 40`, `/dj mute`: the volume part of `/dj`, or undefined for anything else. */
249function djVolume(args: string): VolumeChange | 'show' | undefined {
250 const [first = '', ...rest] = args.trim().split(/\s+/)
251 const word = first.toLowerCase()
252 if (word === 'vol' || word === 'volume') return volumeOf(rest.join(' '), live.step)
253 if (word === 'mute' || word === 'unmute') return volumeOf(word, live.step)
254
255 return undefined
256}
257
258// Spotify Web API: search, play, queue and like, signed in with PKCE.
259
260async function setSpotify($: EngineInterface, status: SpotifyStatus | null): Promise<void> {
261 await update($, spotifyAtom, () => status)
262}
263
264/** A fresh access token, refreshed when it is about to expire; undefined when not signed in. */
265async function accessToken($: EngineInterface): Promise<string | undefined> {
266 const account = asAccount(await $.store.get(SPOTIFY_STORE))
267 if (account === undefined) return undefined
268 const now = await $.clock.now()
269 if (account.expiresAt - 60_000 > now) return account.accessToken
270 const answer = await $.http.fetch(`${ACCOUNTS}/api/token`, {
271 method: 'POST',
272 headers: FORM,
273 body: refreshBody(live.clientId, account.refreshToken),
274 })
275 const tokens = parseTokens(answer.text, now, account.refreshToken)
276 if ('error' in tokens) {
277 // Revoked or from another app: the sign-in is no good any more.
278 if (answer.status === 400) {
279 await $.store.delete(SPOTIFY_STORE)
280 await setSpotify($, null)
281 }
282 return undefined
283 }
284 await $.store.set(SPOTIFY_STORE, { ...account, ...tokens })
285
286 return tokens.accessToken
287}
288
289/** One Web API call as the signed-in account; `{ error }` when there is no account to call as. */
290async function spotify($: EngineInterface, method: string, path: string, body?: unknown): Promise<SpotifyAnswer> {
291 if (live.clientId === '') return { error: SETUP }
292 const access = await accessToken($)
293 if (access === undefined) return { error: 'Not connected to Spotify: run /spotify login.' }
294 const headers: Record<string, string> = { authorization: `Bearer ${access}` }
295 if (body !== undefined) headers['content-type'] = 'application/json'
296 const answer = await $.http.fetch(`${API}${path}`, {
297 method,
298 headers,
299 body: body === undefined ? (method === 'GET' ? undefined : '') : JSON.stringify(body),
300 })
301
302 return { status: answer.status, text: answer.text }
303}
304
305async function spotifyLogin($: EngineInterface): Promise<string> {
306 if (live.clientId === '') return SETUP
307 if ((await $.env.get('OS')) !== 'Windows_NT') return 'Spotify sign-in needs Windows in this version of claude-dj.'
308 const { verifier, challenge } = await pkce()
309 const state = randomText(16)
310 const temp = slashes((await $.env.get('TEMP')) ?? (await $.env.get('TMP')) ?? '.').replace(/\/$/, '')
311 const script = `${temp}/${PLUGIN}/login/listen.ps1`
312 await $.fs.write(script, CALLBACK_LISTENER)
313 const url = authorizeUrl(live.clientId, challenge, state)
314 void finishLogin($, script, state, verifier)
315 await $.process.run(openArgv(url), { timeoutMs: 10_000 }).catch(() => undefined)
316
317 return `Opened the Spotify sign-in in your browser. If it did not open, go to:\n${url}`
318}
319
320/** Waits for Spotify's redirect, trades the code for tokens and says who signed in. */
321async function finishLogin($: EngineInterface, script: string, state: string, verifier: string): Promise<void> {
322 let buffer = ''
323 let said: { code: string } | { error: string } | undefined
324 try {
325 for await (const { stream, text } of $.process.spawn({ argv: listenerArgv(script, state) })) {
326 if (stream !== 'stdout') continue
327 const { lines, rest } = splitLines(`${buffer}${text}`)
328 buffer = rest
329 for (const one of lines) said ??= parseCallback(one)
330 }
331 } catch (error) {
332 said ??= { error: String(error) }
333 }
334 said ??= parseCallback(buffer) ?? { error: 'the sign-in stopped without an answer' }
335 try {
336 if ('error' in said) {
337 $.ui.toast(`Spotify sign-in failed: ${said.error}`)
338 return
339 }
340 const now = await $.clock.now()
341 const answer = await $.http.fetch(`${ACCOUNTS}/api/token`, {
342 method: 'POST',
343 headers: FORM,
344 body: codeExchangeBody(live.clientId, said.code, verifier),
345 })
346 const tokens = parseTokens(answer.text, now)
347 if ('error' in tokens) {
348 $.ui.toast(`Spotify sign-in failed: ${tokens.error}`)
349 return
350 }
351 const me = await $.http.fetch(`${API}/me`, { headers: { authorization: `Bearer ${tokens.accessToken}` } })
352 const who = parseAccount(me.text)
353 await $.store.set(SPOTIFY_STORE, { ...tokens, ...who })
354 await setSpotify($, who)
355 $.ui.toast(
356 who.product === 'premium'
357 ? `Connected to Spotify as ${who.name}`
358 : `Connected to Spotify as ${who.name}; playing and queueing need Premium, search works`,
359 )
360 } catch {
361 // The module reloaded mid sign-in; run /spotify login again.
362 }
363}
364
365async function spotifyLogout($: EngineInterface): Promise<string> {
366 await $.store.delete(SPOTIFY_STORE)
367 await setSpotify($, null)
368
369 return 'Signed out of Spotify. (To revoke access fully, remove the app at https://www.spotify.com/account/apps/)'
370}
371
372async function spotifySearch($: EngineInterface, kind: Kind, query: string): Promise<Item[] | { error: string }> {
373 const answer = await spotify($, 'GET', `/search?${new URLSearchParams({ q: query, type: kind, limit: '5' }).toString()}`)
374 if ('error' in answer) return answer
375 if (answer.status >= 300) return { error: errorText(answer.status, answer.text) }
376
377 return searchItems(answer.text, kind)
378}
379
380/** A URI as given, or the best search match: its URI and how to name it. */
381async function findTarget($: EngineInterface, kind: Kind, query: string): Promise<{ uri: string; label: string } | { error: string }> {
382 const uri = spotifyUri(query)
383 if (uri !== undefined) return { uri, label: uri }
384 if (query.trim() === '') return { error: 'Say what to look for, e.g. /spotify daft punk one more time.' }
385 const found = await spotifySearch($, kind, query)
386 if ('error' in found) return found
387 const best = found[0]
388
389 return best === undefined ? { error: `Nothing on Spotify matches "${query}".` } : { uri: best.uri, label: itemLine(best) }
390}
391
392/** A device to play on; starts the Spotify app here when Spotify runs nowhere. */
393async function wakeDevice($: EngineInterface): Promise<Device | string> {
394 for (let tries = 0; tries < 6; tries++) {
395 const answer = await spotify($, 'GET', '/me/player/devices')
396 if ('error' in answer) return answer.error
397 const device = pickDevice(parseDevices(answer.text))
398 if (device !== undefined) return device
399 if (tries === 0) await $.process.run(openArgv('spotify:'), { timeoutMs: 10_000 }).catch(() => undefined)
400 await $.clock.sleep(1_000)
401 }
402
403 return 'Spotify is not open on any device. I tried to start the Spotify app here: open it and try again.'
404}
405
406/** Sends a play request, waking a device when none is active; undefined when it played. */
407async function playOn($: EngineInterface, body: unknown): Promise<string | undefined> {
408 let answer = await spotify($, 'PUT', '/me/player/play', body)
409 if ('error' in answer) return answer.error
410 if (isNoActiveDevice(answer.status, answer.text)) {
411 const device = await wakeDevice($)
412 if (typeof device === 'string') return device
413 answer = await spotify($, 'PUT', `/me/player/play?device_id=${encodeURIComponent(device.id)}`, body)
414 if ('error' in answer) return answer.error
415 }
416
417 return answer.status < 300 ? undefined : errorText(answer.status, answer.text)
418}
419
420async function spotifyPlay($: EngineInterface, kind: Kind, query: string): Promise<string> {
421 const target = await findTarget($, kind, query)
422 if ('error' in target) return target.error
423 const kindOf = kindOfUri(target.uri)
424 const body = kindOf === 'track' || kindOf === 'episode' ? { uris: [target.uri] } : { context_uri: target.uri }
425 const failed = await playOn($, body)
426
427 return failed ?? `Playing ${target.label} on Spotify.`
428}
429
430async function spotifyQueue($: EngineInterface, query: string): Promise<string> {
431 const target = await findTarget($, 'track', query)
432 if ('error' in target) return target.error
433 const answer = await spotify($, 'POST', `/me/player/queue?uri=${encodeURIComponent(target.uri)}`)
434 if ('error' in answer) return answer.error
435 if (isNoActiveDevice(answer.status, answer.text)) return 'Spotify is not playing anywhere; play something first, then queue.'
436
437 return answer.status < 300 ? `Queued ${target.label}.` : errorText(answer.status, answer.text)
438}
439
440async function spotifyLike($: EngineInterface): Promise<string> {
441 const now = await spotify($, 'GET', '/me/player/currently-playing')
442 if ('error' in now) return now.error
443 const item = now.status === 200 ? currentItem(now.text) : undefined
444 if (item === undefined) return 'Nothing is playing on Spotify to like.'
445 const saved = await spotify($, 'PUT', `/me/library?uris=${encodeURIComponent(item.uri)}`)
446 if ('error' in saved) return saved.error
447
448 return saved.status < 300 ? `Liked "${item.name}": it is in your Liked Songs.` : errorText(saved.status, saved.text)
449}
450
451async function spotifyDevices($: EngineInterface): Promise<string> {
452 const answer = await spotify($, 'GET', '/me/player/devices')
453 if ('error' in answer) return answer.error
454 if (answer.status >= 300) return errorText(answer.status, answer.text)
455 const devices = parseDevices(answer.text)
456 if (devices.length === 0) return 'Spotify is not open on any device.'
457
458 return [
459 'Spotify devices (switch with /spotify device <name>):',
460 ...devices.map(one => `${one.isActive ? '▶' : '·'} ${one.name} (${one.type})`),
461 ].join('\n')
462}
463
464async function spotifyDevice($: EngineInterface, name: string): Promise<string> {
465 const answer = await spotify($, 'GET', '/me/player/devices')
466 if ('error' in answer) return answer.error
467 const wanted = name.trim().toLowerCase()
468 const device = parseDevices(answer.text).find(one => one.name.toLowerCase().includes(wanted))
469 if (wanted === '' || device === undefined) return `No Spotify device matches "${name.trim()}"; see /spotify devices.`
470 const moved = await spotify($, 'PUT', '/me/player', { device_ids: [device.id], play: true })
471 if ('error' in moved) return moved.error
472
473 return moved.status < 300 ? `Playing on ${device.name}.` : errorText(moved.status, moved.text)
474}
475
476async function spotifyStatus($: EngineInterface): Promise<string> {
477 if (live.clientId === '') return SETUP
478 const account = asAccount(await $.store.get(SPOTIFY_STORE))
479 if (account === undefined) return 'Spotify is set up but not signed in: run /spotify login.'
480 const plan = account.product === 'premium' ? 'Premium' : `${account.product || 'unknown'} plan: playing and queueing need Premium`
481
482 return `Connected to Spotify as ${account.name} (${plan}). Try /spotify <song>, /spotify playlist <name>, /spotify queue <song>, /spotify like.`
483}
484
485async function spotifyCommand($: EngineInterface, args: string): Promise<string> {
486 const command = spotifyCommandOf(args)
487 switch (command.verb) {
488 case 'status':
489 return spotifyStatus($)
490 case 'login':
491 return spotifyLogin($)
492 case 'logout':
493 return spotifyLogout($)
494 case 'like':
495 return spotifyLike($)
496 case 'devices':
497 return spotifyDevices($)
498 case 'device':
499 return spotifyDevice($, command.name)
500 case 'queue':
501 return spotifyQueue($, command.query)
502 case 'play':
503 return spotifyPlay($, command.kind, command.query)
504 }
505}
506
507async function likeFromBand($: EngineInterface): Promise<void> {
508 $.ui.toast(await spotifyLike($))
509}
510
511// The /music pane: cover, progress bar, controls.
512
513/** One tick: moves the progress bar while music plays; stops itself once the pane is closed. */
514async function tick($: EngineInterface): Promise<void> {
515 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
516 if (!isOpen) {
517 live.ticker?.cancel()
518 live.ticker = undefined
519 return
520 }
521 const player = await read($, playerAtom)
522 if (player.kind === 'ready' && isPlaying(player.track)) await update($, tickAtom, n => n + 1)
523}
524
525function startTicker($: EngineInterface): void {
526 live.ticker ??= $.clock.every(1_000, () => void tick($).catch(() => undefined))
527}
528
529async function openMusic($: EngineInterface): Promise<string> {
530 await $.ui.open({ id: PANE, title: 'Now playing', focus: true })
531 startTicker($)
532
533 return 'Now playing pane opened.'
534}
535
536/** The cover JPEG as base64, read once per file; undefined when there is none or it is gone. */
537async function coverBase64($: EngineInterface, path: string): Promise<string | undefined> {
538 if (path === '') return undefined
539 if (live.cover?.path === path) return live.cover.base64
540 try {
541 const { base64 } = await $.fs.read(path, { as: 'bytes' })
542 live.cover = { path, base64 }
543
544 return base64
545 } catch {
546 return undefined
547 }
548}
549
550async function heartbeat($: EngineInterface): Promise<void> {
551 if (live.workDir !== undefined) await $.fs.write(`${live.workDir}/heartbeat`, 'alive')
552}
553
554export const register: Register = (on, options) => {
555 live.showBand = options.showBand !== false
556 live.step = typeof options.volumeStep === 'number' && options.volumeStep > 0 ? Math.min(50, options.volumeStep) : 10
557 live.clientId = String(options.spotifyClientId ?? '').trim()
558 const musicTools = options.musicTools !== false
559
560 on('session.start', async ($, e, next) => {
561 const started = await next(e)
562 await $.command.register({
563 name: 'music',
564 description: 'Open the Now playing pane: cover art, progress bar and controls',
565 immediate: true,
566 })
567 await $.command.register({
568 name: 'spotify',
569 description: 'Spotify: play a song, album or playlist, queue, like, switch device, sign in',
570 argumentHint: SPOTIFY_HINT,
571 immediate: true,
572 })
573 const account = live.clientId === '' ? undefined : asAccount(await $.store.get(SPOTIFY_STORE))
574 await setSpotify($, account === undefined ? null : { name: account.name, product: account.product })
575 await update($, hiddenAtom, () => false)
576 if ((await $.store.get(HIDDEN_STORE)) === true) await update($, hiddenAtom, () => true)
577 for (const command of COMMANDS) {
578 await $.command.register({
579 name: command.name,
580 description: command.description,
581 argumentHint: command.isVolume === true ? VOLUME_HINT : command.action === undefined ? DJ_HINT : undefined,
582 immediate: true,
583 })
584 }
585 if (musicTools) {
586 await $.tool.register({
587 name: 'now_playing',
588 description:
589 'Says what music or media is playing on this computer right now (YouTube Music, Spotify, a browser tab, ...): title, artist, album, app, whether it plays or is paused, and the volume.',
590 inputSchema: { type: 'object', properties: {} },
591 })
592 await $.tool.register({
593 name: 'music_control',
594 description:
595 'Controls the music or media playing on this computer: play, pause, toggle, next or previous track. Use it when the user asks you to change their music. Answers with what plays afterwards.',
596 inputSchema: {
597 type: 'object',
598 properties: { action: { type: 'string', enum: [...ACTIONS] } },
599 required: ['action'],
600 },
601 })
602 await $.tool.register({
603 name: 'music_volume',
604 description:
605 "Changes this computer's output volume (the one the volume keys change): set a level, change it by an amount, or mute and unmute. Use it when the user asks for the music louder, quieter or muted. Answers with the volume afterwards.",
606 inputSchema: {
607 type: 'object',
608 properties: {
609 level: { type: 'integer', minimum: 0, maximum: 100, description: 'Set the volume to this percentage.' },
610 change: { type: 'integer', minimum: -100, maximum: 100, description: 'Change the volume by this many percent.' },
611 mute: { type: 'boolean', description: 'true mutes, false unmutes.' },
612 },
613 },
614 })
615 }
616 if (musicTools && live.clientId !== '') {
617 const kind = { type: 'string', enum: [...KINDS], description: 'What to look for; track when left out.' }
618 await $.tool.register({
619 name: 'spotify_search',
620 description:
621 "Searches Spotify for tracks, albums, artists or playlists and lists the top 5 with their Spotify URIs. Use it to pick what to play or queue for the user.",
622 inputSchema: {
623 type: 'object',
624 properties: { query: { type: 'string', description: 'Search words, e.g. "lofi beats" or "daft punk".' }, type: kind },
625 required: ['query'],
626 },
627 })
628 await $.tool.register({
629 name: 'spotify_play',
630 description:
631 'Plays something on the user\'s Spotify right away: the best match for a search, or an exact Spotify URI or open.spotify.com link. Albums, artists and playlists play as a whole. Use it when the user asks you to put something on.',
632 inputSchema: {
633 type: 'object',
634 properties: { query: { type: 'string', description: 'Search words, a spotify: URI or an open.spotify.com link.' }, type: kind },
635 required: ['query'],
636 },
637 })
638 await $.tool.register({
639 name: 'spotify_queue',
640 description: "Adds a track to the user's Spotify queue: the best match for a search, or an exact Spotify track URI or link.",
641 inputSchema: {
642 type: 'object',
643 properties: { query: { type: 'string', description: 'Search words, a spotify:track: URI or a track link.' } },
644 required: ['query'],
645 },
646 })
647 await $.tool.register({
648 name: 'spotify_like',
649 description: "Saves the track playing on Spotify now to the user's Liked Songs.",
650 inputSchema: { type: 'object', properties: {} },
651 })
652 }
653
654 if ((await $.env.get('OS')) !== 'Windows_NT') {
655 await setPlayer($, {
656 kind: 'unavailable',
657 reason: 'this version drives Windows media sessions only; macOS and Linux support is coming.',
658 })
659 return started
660 }
661 await setPlayer($, { kind: 'starting' })
662 await startWatcher($, 0)
663 $.clock.every(HEARTBEAT_MS, () => void heartbeat($).catch(() => undefined))
664 // A reload while the pane stayed open: keep its progress bar moving.
665 if ((await $.ui.panes()).some(pane => pane.id === PANE)) startTicker($)
666
667 return started
668 })
669
670 for (const command of COMMANDS) {
671 on('command.run', { command: command.name }, async ($, e) => {
672 if (command.isVolume === true) {
673 const change = volumeOf(e.args, live.step)
674 if (change === undefined) return { text: `Unknown volume "${e.args.trim()}". Try /vol ${VOLUME_HINT}.` }
675
676 return { text: await volume($, change) }
677 }
678 const words = e.args.trim()
679 if (command.action === undefined && /^(hide|show)$/i.test(words)) {
680 return { text: await setHidden($, words.toLowerCase() === 'hide') }
681 }
682 const change = command.action === undefined ? djVolume(e.args) : undefined
683 if (change !== undefined) return { text: await volume($, change) }
684 // `/play some song` and `/dj play some song` search Spotify; bare `/play` resumes.
685 const song = command.action === 'play' ? words : /^play\s+/i.test(words) ? words.replace(/^play\s+/i, '') : ''
686 if (song !== '') return { text: await spotifyPlay($, 'track', song) }
687 const action = command.action ?? actionOf(e.args)
688 if (action === undefined) return { text: `Unknown action "${e.args.trim()}". Try /dj ${DJ_HINT}.` }
689
690 return { text: await transport($, action) }
691 })
692 }
693
694 on('tool.call', { tool: NOW_PLAYING }, async $ => {
695 const player = await read($, playerAtom)
696 if (player.kind === 'unavailable') return { result: `Music control is unavailable: ${player.reason}` }
697 if (player.kind === 'starting') return { result: 'The music watcher is still starting; ask again in a moment.' }
698
699 return { result: `${describe(player.track)} ${describeVolume(player.volume)}` }
700 })
701
702 on('tool.call', { tool: CONTROL }, async ($, e) => {
703 const input = e as unknown as { action?: unknown }
704 const action = ACTIONS.find(one => one === input.action)
705 if (action === undefined) return { result: `Unknown action; use one of: ${ACTIONS.join(', ')}.` }
706
707 return { result: await transport($, action) }
708 })
709
710 on('tool.call', { tool: VOLUME }, async ($, e) => {
711 const input = e as unknown as { level?: unknown; change?: unknown; mute?: unknown }
712 let change: VolumeChange | undefined
713 if (typeof input.level === 'number') change = { kind: 'set', level: input.level }
714 else if (typeof input.change === 'number') change = { kind: 'step', by: input.change }
715 else if (typeof input.mute === 'boolean') change = { kind: input.mute ? 'mute' : 'unmute' }
716 if (change === undefined) return { result: 'Give one of: level (0-100), change (-100 to 100) or mute (true/false).' }
717
718 return { result: await volume($, change) }
719 })
720
721 on('command.run', { command: 'spotify' }, async ($, e) => ({ text: await spotifyCommand($, e.args) }))
722
723 on('command.run', { command: 'music' }, async $ => ({ text: await openMusic($) }))
724
725 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
726 const ui = $.ui.resolve(e)
727 const { Box, Button, Text } = ui
728 // Read so each tick draws the pane again and the bar moves.
729 await read($, tickAtom)
730 const player = await read($, playerAtom)
731 if (player.kind === 'unavailable') return <Text dimColor>{`Music control is unavailable: ${player.reason}`}</Text>
732 if (player.kind === 'starting') return <Text dimColor>Looking for music…</Text>
733 const track = player.track
734 if (track === null) {
735 return <Text dimColor>Nothing is playing. Start something in YouTube Music, Spotify or any player.</Text>
736 }
737 const playing = isPlaying(track)
738 const sound = player.volume
739 const canLike = appName(track.app) === 'Spotify' && (await read($, spotifyAtom)) !== null
740 const columns = e.props.bodyColumns
741 const isWide = columns >= 64
742 const alt = `Cover of ${trackLine(track)}`
743
744 let cover
745 if (e.surface === 'terminal') {
746 // Pixels where the terminal draws images (kitty, Ghostty); the alt text elsewhere.
747 if ('Image' in ui && track.cover !== '') {
748 const size = isWide ? 20 : Math.min(20, columns - 2)
749 cover = <ui.Image source={{ file: track.cover, format: 'png' }} columns={size} rows={Math.round(size / 2)} alt="♪" />
750 }
751 } else if ('Svg' in ui) {
752 cover = <ui.Svg source={coverSvg(await coverBase64($, track.coverJpg), 140)} alt={alt} width={140} height={140} />
753 }
754
755 const position = positionNow(track, await $.clock.now())
756 const hasTime = track.duration > 0
757 const barWidth = Math.max(10, (isWide && cover !== undefined ? columns - 26 : columns) - 14)
758
759 const info = (
760 <Box flexDirection="column" gap={0}>
761 <Text bold>{trackLine({ ...track, artist: '' }, 70)}</Text>
762 {track.artist !== '' && <Text>{track.artist}</Text>}
763 <Text dimColor>{[track.album, appName(track.app)].filter(part => part !== '').join(' · ')}</Text>
764 {hasTime && (
765 <Text dimColor={!playing}>
766 {`${formatTime(position)} ${progressBar(position, track.duration, barWidth)} ${formatTime(track.duration)}`}
767 </Text>
768 )}
769 <Box flexDirection="row" gap={1} flexWrap="wrap">
770 <Button key="pane-prev" label="⏮" hotkey="p" onPress={() => void send($, 'previous')} />
771 <Button
772 key="pane-toggle"
773 label={playing ? '⏸ Pause' : '▶ Play'}
774 hotkey="k"
775 variant="primary"
776 onPress={() => void send($, 'toggle')}
777 />
778 <Button key="pane-next" label="⏭" hotkey="n" onPress={() => void send($, 'next')} />
779 {canLike && <Button key="pane-like" label="♥ Like" hotkey="l" onPress={() => likeFromBand($)} />}
780 </Box>
781 {sound !== null && (
782 <Box flexDirection="row" gap={1}>
783 <Button key="pane-mute" label={sound.muted ? '🔇' : '🔊'} hotkey="m" onPress={() => void send($, 'togglemute')} />
784 <Button key="pane-voldown" label="−" hotkey="d" onPress={() => void send($, `volume -${live.step}`)} />
785 <Text dimColor={sound.muted}>{sound.muted ? 'muted' : `${sound.level}%`}</Text>
786 <Button key="pane-volup" label="+" hotkey="u" onPress={() => void send($, `volume +${live.step}`)} />
787 </Box>
788 )}
789 </Box>
790 )
791
792 return cover === undefined ? (
793 info
794 ) : (
795 <Box flexDirection={isWide ? 'row' : 'column'} gap={2}>
796 {cover}
797 {info}
798 </Box>
799 )
800 })
801
802 /** The `type` a Spotify tool was given, or track. */
803 const kindIn = (input: { type?: unknown }): Kind => KINDS.find(one => one === input.type) ?? 'track'
804
805 on('tool.call', { tool: SPOTIFY_SEARCH }, async ($, e) => {
806 const input = e as unknown as { query?: unknown; type?: unknown }
807 const kind = kindIn(input)
808 const query = typeof input.query === 'string' ? input.query : ''
809 const found = await spotifySearch($, kind, query)
810 if ('error' in found) return { result: found.error }
811 if (found.length === 0) return { result: `Nothing on Spotify matches "${query}".` }
812
813 return { result: found.map((item, index) => `${index + 1}. ${itemLine(item)} ${item.uri}`).join('\n') }
814 })
815
816 on('tool.call', { tool: SPOTIFY_PLAY }, async ($, e) => {
817 const input = e as unknown as { query?: unknown; type?: unknown }
818
819 return { result: await spotifyPlay($, kindIn(input), typeof input.query === 'string' ? input.query : '') }
820 })
821
822 on('tool.call', { tool: SPOTIFY_QUEUE }, async ($, e) => {
823 const input = e as unknown as { query?: unknown }
824
825 return { result: await spotifyQueue($, typeof input.query === 'string' ? input.query : '') }
826 })
827
828 on('tool.call', { tool: SPOTIFY_LIKE }, async $ => ({ result: await spotifyLike($) }))
829
830 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
831 if (!live.showBand || e.props.hasSurvey || (await read($, hiddenAtom))) return next(e)
832 const player = await read($, playerAtom)
833 if (player.kind !== 'ready' || player.track === null) return next(e)
834 const track = player.track
835 const playing = isPlaying(track)
836 const sound = player.volume
837 // ♥ saves to Liked Songs: only while Spotify itself plays and the sign-in is there.
838 const canLike = appName(track.app) === 'Spotify' && (await read($, spotifyAtom)) !== null
839 const { Box, Button, Text } = $.ui.resolve(e)
840
841 return (
842 <Box flexDirection="row" gap={1}>
843 <Text dimColor={!playing}>{`${playing ? '♪' : '⏸'} ${trackLine(track, 60)}`}</Text>
844 <Text dimColor>{`· ${appName(track.app)}`}</Text>
845 <Button key="prev" label="⏮" onPress={() => void send($, 'previous')} />
846 <Button key="toggle" label={playing ? '⏸' : '▶'} onPress={() => void send($, 'toggle')} />
847 <Button key="next" label="⏭" onPress={() => void send($, 'next')} />
848 {canLike && <Button key="like" label="♥" onPress={() => likeFromBand($)} />}
849 {sound !== null && (
850 <Box flexDirection="row" gap={1}>
851 <Button key="mute" label={sound.muted ? '🔇' : '🔊'} onPress={() => void send($, 'togglemute')} />
852 <Button key="voldown" label="−" onPress={() => void send($, `volume -${live.step}`)} />
853 <Text dimColor={sound.muted}>
854 {sound.muted ? 'muted' : `${sound.level}%`}
855 </Text>
856 <Button key="volup" label="+" onPress={() => void send($, `volume +${live.step}`)} />
857 </Box>
858 )}
859 <Button key="hide" label="✕" dimColor onPress={() => hideFromBand($)} />
860 </Box>
861 )
862 })
863}
864hooks/dj.ts 199 lines1import type { Track, Volume } from '../types'
2
3// Pure helpers: what the watcher says, what the person typed, what to show.
4
5export const ACTIONS = ['play', 'pause', 'toggle', 'next', 'previous'] as const
6export type Action = (typeof ACTIONS)[number]
7
8const ALIASES: Record<string, Action> = {
9 play: 'play',
10 resume: 'play',
11 pause: 'pause',
12 stop: 'pause',
13 toggle: 'toggle',
14 next: 'next',
15 skip: 'next',
16 prev: 'previous',
17 previous: 'previous',
18 back: 'previous',
19}
20
21/** `/dj next` → `next`; an empty argument toggles; anything else is unknown. */
22export function actionOf(args: string): Action | undefined {
23 const word = args.trim().toLowerCase()
24
25 return word === '' ? 'toggle' : ALIASES[word]
26}
27
28export type WatcherLine = { track: Track | null; volume: Volume | null } | { error: string }
29
30/** One line the watcher printed, or undefined for a line that is not its JSON. */
31export function parseLine(line: string): WatcherLine | undefined {
32 const trimmed = line.trim()
33 if (!trimmed.startsWith('{')) return undefined
34 let raw: unknown
35 try {
36 raw = JSON.parse(trimmed)
37 } catch {
38 return undefined
39 }
40 if (typeof raw !== 'object' || raw === null) return undefined
41 const o = raw as Record<string, unknown>
42 if (typeof o.error === 'string') return { error: o.error }
43 const text = (v: unknown): string => (typeof v === 'string' ? v : '')
44 const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
45 // -1 (or nothing) when the watcher could not read the output device.
46 const volume: Volume | null =
47 typeof o.volume === 'number' && o.volume >= 0 ? { level: Math.round(o.volume), muted: o.muted === true } : null
48 if (o.none === true) return { track: null, volume }
49 if (typeof o.title !== 'string') return undefined
50
51 return {
52 volume,
53 track: {
54 app: text(o.app),
55 title: o.title,
56 artist: text(o.artist),
57 album: text(o.album),
58 status: text(o.status),
59 position: num(o.position),
60 positionAt: num(o.positionAt),
61 duration: num(o.duration),
62 cover: text(o.cover),
63 coverJpg: text(o.coverJpg),
64 },
65 }
66}
67
68/** Splits streamed text into whole lines, keeping the unfinished tail. */
69export function splitLines(buffer: string): { lines: string[]; rest: string } {
70 const parts = buffer.split(/\r?\n/)
71 const rest = parts.pop() ?? ''
72
73 return { lines: parts, rest }
74}
75
76export const isPlaying = (track: Track | null): boolean => track?.status === 'Playing'
77
78/** Where the track is now: the reported position, moved on by the time since while it plays. */
79export function positionNow(track: Track, now: number): number {
80 const moved = isPlaying(track) && track.positionAt > 0 ? Math.max(0, (now - track.positionAt) / 1000) : 0
81 const at = track.position + moved
82
83 return track.duration > 0 ? Math.min(track.duration, at) : at
84}
85
86/** `━━━━●──────`, `width` cells long, the knob where `position` is of `duration`. */
87export function progressBar(position: number, duration: number, width: number): string {
88 const cells = Math.max(5, Math.floor(width))
89 const share = duration > 0 ? Math.min(1, Math.max(0, position / duration)) : 0
90 const done = Math.round(share * (cells - 1))
91
92 return `${'━'.repeat(done)}●${'─'.repeat(cells - 1 - done)}`
93}
94
95/** The cover as an SVG of `size` px with rounded corners; a note on a plain square without one. */
96export function coverSvg(jpegBase64: string | undefined, size: number): string {
97 const head = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 100 100">`
98 if (jpegBase64 === undefined) {
99 return `${head}<rect width="100" height="100" rx="8" fill="#8884"/><text x="50" y="64" font-size="44" text-anchor="middle" fill="#8888">♪</text></svg>`
100 }
101
102 return `${head}<defs><clipPath id="c"><rect width="100" height="100" rx="8"/></clipPath></defs><image href="data:image/jpeg;base64,${jpegBase64}" width="100" height="100" clip-path="url(#c)" preserveAspectRatio="xMidYMid slice"/></svg>`
103}
104
105export function formatTime(seconds: number): string {
106 const s = Math.max(0, Math.round(seconds))
107 const h = Math.floor(s / 3600)
108 const m = Math.floor((s % 3600) / 60)
109 const pad = (n: number): string => String(n).padStart(2, '0')
110
111 return h > 0 ? `${h}:${pad(m)}:${pad(s % 60)}` : `${m}:${pad(s % 60)}`
112}
113
114/** A friendly name for the app behind the media session. */
115export function appName(app: string): string {
116 const id = app.toLowerCase()
117 if (id.includes('spotify')) return 'Spotify'
118 if (id.includes('youtube') || id.includes('ytm')) return 'YouTube Music'
119 if (id.includes('chrome')) return 'Chrome'
120 if (id.includes('msedge') || id.includes('edge')) return 'Edge'
121 if (id.includes('firefox') || id === '308046b0af4a39cb') return 'Firefox'
122 if (id.includes('brave')) return 'Brave'
123
124 return app.replace(/\.exe$/i, '').split(/[!\\/]/).at(-1) ?? app
125}
126
127/** `Title — Artist`, shortened to `max` characters. */
128export function trackLine(track: Track, max = 80): string {
129 const line = track.artist === '' ? track.title : `${track.title} — ${track.artist}`
130
131 return line.length > max ? `${line.slice(0, max - 1)}…` : line
132}
133
134/** What the model reads from the now_playing tool and after a control. */
135export function describe(track: Track | null): string {
136 if (track === null) return 'Nothing is playing: no app has an active media session.'
137 const parts = [
138 `${track.status}: "${track.title}"`,
139 track.artist !== '' ? `by ${track.artist}` : '',
140 track.album !== '' ? `from "${track.album}"` : '',
141 `in ${appName(track.app)}`,
142 ].filter(part => part !== '')
143 const time = track.duration > 0 ? ` (${formatTime(track.position)} / ${formatTime(track.duration)})` : ''
144
145 return `${parts.join(' ')}${time}.`
146}
147
148export const PAST: Record<Action, string> = {
149 play: 'Playing',
150 pause: 'Paused',
151 toggle: 'Toggled play/pause',
152 next: 'Skipped to the next track',
153 previous: 'Went back a track',
154}
155
156export type VolumeChange =
157 | { kind: 'set'; level: number }
158 | { kind: 'step'; by: number }
159 | { kind: 'mute' | 'unmute' | 'togglemute' }
160
161/**
162 * `/vol 40`, `/vol +5`, `/vol up`, `/vol mute`; `show` for no argument,
163 * undefined for words it does not know. `step` is what up and down move.
164 */
165export function volumeOf(args: string, step: number): VolumeChange | 'show' | undefined {
166 const word = args.trim().toLowerCase().replace(/%$/, '')
167 if (word === '') return 'show'
168 if (word === 'up' || word === '+' || word === 'louder') return { kind: 'step', by: step }
169 if (word === 'down' || word === '-' || word === 'quieter' || word === 'softer') return { kind: 'step', by: -step }
170 if (word === 'mute' || word === 'unmute') return { kind: word }
171 if (word === 'togglemute' || word === 'toggle') return { kind: 'togglemute' }
172 if (/^[+-]\d{1,3}$/.test(word)) return { kind: 'step', by: Number(word) }
173 if (/^\d{1,3}$/.test(word) && Number(word) <= 100) return { kind: 'set', level: Number(word) }
174
175 return undefined
176}
177
178/** The watcher's command line for a volume change. */
179export function volumeCommand(change: VolumeChange): string {
180 if (change.kind === 'set') return `volume ${Math.max(0, Math.min(100, Math.round(change.level)))}`
181 if (change.kind === 'step') return `volume ${change.by >= 0 ? '+' : '-'}${Math.min(100, Math.abs(Math.round(change.by)))}`
182
183 return change.kind
184}
185
186export function describeVolume(volume: Volume | null): string {
187 if (volume === null) return 'The volume is unknown: no output device answered.'
188
189 return volume.muted ? `The volume is muted (${volume.level}% when unmuted).` : `The volume is ${volume.level}%.`
190}
191
192export function volumePast(change: VolumeChange): string {
193 if (change.kind === 'set') return `Volume set to ${change.level}%`
194 if (change.kind === 'step') return change.by >= 0 ? 'Volume up' : 'Volume down'
195 if (change.kind === 'togglemute') return 'Toggled mute'
196
197 return change.kind === 'mute' ? 'Muted' : 'Unmuted'
198}
199hooks/host.ts 340 lines1// The host side: the watcher that reads and drives the OS media session.
2// The hooks module writes it to a work folder and runs it, since only it holds `$`.
3
4export const slashes = (path: string): string => path.replaceAll('\\', '/')
5
6/** How long the watcher lives without a heartbeat from the session. */
7export const HEARTBEAT_MS = 30_000
8export const STALE_SECONDS = 120
9
10/**
11 * Windows: one long-running Windows PowerShell 5.1 loop over the system media
12 * transport controls (SMTC), the same session the volume flyout shows. It
13 * prints a JSON line whenever the track or play state changes, and runs each
14 * `<dir>/commands/*.cmd` file (one command line) it finds, oldest first.
15 * Volume is the default output device's (Core Audio): Windows does not let
16 * one process set another app's mixer level through its media session.
17 * Kept ASCII: PowerShell 5.1 reads a script without a BOM as ANSI.
18 */
19export const WINDOWS_WATCHER = String.raw`param([Parameter(Mandatory = $true)][string]$Dir)
20$ErrorActionPreference = 'Stop'
21[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
22Add-Type -AssemblyName System.Runtime.WindowsRuntime
23Add-Type -TypeDefinition @'
24using System;
25using System.Runtime.InteropServices;
26
27namespace ClaudeDj {
28 [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] class MMDeviceEnumeratorCom {}
29
30 [ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
31 interface IMMDeviceEnumerator {
32 void EnumAudioEndpoints(int dataFlow, int stateMask, out IntPtr devices);
33 void GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice device);
34 }
35
36 [ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
37 interface IMMDevice {
38 void Activate(ref Guid iid, int clsCtx, IntPtr activationParams, [MarshalAs(UnmanagedType.IUnknown)] out object o);
39 }
40
41 [ComImport, Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
42 interface IAudioEndpointVolume {
43 void RegisterControlChangeNotify(IntPtr notify);
44 void UnregisterControlChangeNotify(IntPtr notify);
45 void GetChannelCount(out uint count);
46 void SetMasterVolumeLevel(float levelDb, ref Guid context);
47 void SetMasterVolumeLevelScalar(float level, ref Guid context);
48 void GetMasterVolumeLevel(out float levelDb);
49 void GetMasterVolumeLevelScalar(out float level);
50 void SetChannelVolumeLevel(uint channel, float levelDb, ref Guid context);
51 void SetChannelVolumeLevelScalar(uint channel, float level, ref Guid context);
52 void GetChannelVolumeLevel(uint channel, out float levelDb);
53 void GetChannelVolumeLevelScalar(uint channel, out float level);
54 void SetMute([MarshalAs(UnmanagedType.Bool)] bool mute, ref Guid context);
55 void GetMute([MarshalAs(UnmanagedType.Bool)] out bool mute);
56 }
57
58 // A media session's thumbnail as bytes. Windows PowerShell cannot call the
59 // WinRT stream it gets, so read it as a classic COM IStream instead.
60 public static class Thumbnail {
61 [DllImport("shcore.dll")]
62 static extern int CreateStreamOverRandomAccessStream([MarshalAs(UnmanagedType.IUnknown)] object stream, ref Guid iid, out System.Runtime.InteropServices.ComTypes.IStream result);
63
64 public static byte[] ReadAll(object randomAccessStream) {
65 Guid iid = new Guid("0000000c-0000-0000-C000-000000000046");
66 System.Runtime.InteropServices.ComTypes.IStream stream;
67 Marshal.ThrowExceptionForHR(CreateStreamOverRandomAccessStream(randomAccessStream, ref iid, out stream));
68 var bytes = new System.IO.MemoryStream();
69 var buffer = new byte[65536];
70 IntPtr count = Marshal.AllocHGlobal(4);
71 try {
72 while (bytes.Length < 8 * 1024 * 1024) {
73 stream.Read(buffer, buffer.Length, count);
74 int n = Marshal.ReadInt32(count);
75 if (n <= 0) break;
76 bytes.Write(buffer, 0, n);
77 }
78 } finally {
79 Marshal.FreeHGlobal(count);
80 Marshal.ReleaseComObject(stream);
81 }
82 return bytes.ToArray();
83 }
84 }
85
86 // The default output device's volume: what the volume keys and the taskbar flyout change.
87 public static class Volume {
88 static Guid none = Guid.Empty;
89
90 static IAudioEndpointVolume Endpoint() {
91 var enumerator = (IMMDeviceEnumerator)new MMDeviceEnumeratorCom();
92 IMMDevice device;
93 enumerator.GetDefaultAudioEndpoint(0, 1, out device);
94 Guid iid = typeof(IAudioEndpointVolume).GUID;
95 object o;
96 device.Activate(ref iid, 23, IntPtr.Zero, out o);
97 return (IAudioEndpointVolume)o;
98 }
99
100 public static int Level() {
101 float level;
102 Endpoint().GetMasterVolumeLevelScalar(out level);
103 return (int)Math.Round(level * 100);
104 }
105
106 public static bool IsMuted() {
107 bool muted;
108 Endpoint().GetMute(out muted);
109 return muted;
110 }
111
112 // Sets the level (0..100), or changes it by that much; a change of level
113 // unmutes, as the volume flyout does.
114 public static void Set(int level, bool relative) {
115 int target = relative ? Level() + level : level;
116 var endpoint = Endpoint();
117 endpoint.SetMasterVolumeLevelScalar(Math.Max(0, Math.Min(100, target)) / 100f, ref none);
118 endpoint.SetMute(false, ref none);
119 }
120
121 public static void Mute(bool mute) {
122 Endpoint().SetMute(mute, ref none);
123 }
124 }
125}
126'@
127$asTask = [System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object {
128 $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation${'`'}1'
129} | Select-Object -First 1
130function Await($op, [Type]$type) {
131 $task = $asTask.MakeGenericMethod($type).Invoke($null, @($op))
132 if (-not $task.Wait(5000)) { throw 'media session call timed out' }
133 $task.Result
134}
135$Manager = [Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager, Windows.Media.Control, ContentType = WindowsRuntime]
136$Props = [Windows.Media.Control.GlobalSystemMediaTransportControlsSessionMediaProperties, Windows.Media.Control, ContentType = WindowsRuntime]
137$mgr = Await ($Manager::RequestAsync()) $Manager
138
139$Stream = [Windows.Storage.Streams.IRandomAccessStreamWithContentType, Windows.Storage.Streams, ContentType = WindowsRuntime]
140Add-Type -AssemblyName System.Drawing
141
142$commands = Join-Path $Dir 'commands'
143$heartbeat = Join-Path $Dir 'heartbeat'
144$covers = Join-Path $Dir 'covers'
145New-Item -ItemType Directory -Force -Path $commands | Out-Null
146New-Item -ItemType Directory -Force -Path $covers | Out-Null
147$script:chosen = ''
148# The cover of the track shown: a PNG for the terminal, a small JPEG for the
149# desktop's SVG. A new track drops the old files; a missing thumbnail (apps
150# often send it after the title) is asked for again for a few seconds.
151$script:coverFor = ''
152$script:cover = ''
153$script:coverJpg = ''
154$script:coverTries = 0
155$script:coverCount = 0
156
157# The centre square of the image, scaled to size x size.
158function SaveSquare($image, [string]$path, [int]$size, $format) {
159 $side = [Math]::Min($image.Width, $image.Height)
160 $from = New-Object System.Drawing.Rectangle ([int](($image.Width - $side) / 2)), ([int](($image.Height - $side) / 2)), $side, $side
161 $to = New-Object System.Drawing.Rectangle 0, 0, $size, $size
162 $bitmap = New-Object System.Drawing.Bitmap $size, $size
163 $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
164 try {
165 $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
166 $graphics.DrawImage($image, $to, $from, [System.Drawing.GraphicsUnit]::Pixel)
167 $bitmap.Save($path, $format)
168 } finally {
169 $graphics.Dispose()
170 $bitmap.Dispose()
171 }
172}
173
174function UpdateCover($properties, [string]$trackKey) {
175 if ($trackKey -ne $script:coverFor) {
176 foreach ($old in @($script:cover, $script:coverJpg)) {
177 if ($old -ne '') { Remove-Item -Force $old -ErrorAction SilentlyContinue }
178 }
179 $script:coverFor = $trackKey
180 $script:cover = ''
181 $script:coverJpg = ''
182 $script:coverTries = 0
183 }
184 if ($script:cover -ne '' -or $script:coverTries -ge 20) { return }
185 $script:coverTries++
186 if ($null -eq $properties.Thumbnail) { return }
187 $bytes = [ClaudeDj.Thumbnail]::ReadAll((Await ($properties.Thumbnail.OpenReadAsync()) $Stream))
188 $stream = New-Object System.IO.MemoryStream (, $bytes)
189 try {
190 $image = [System.Drawing.Image]::FromStream($stream)
191 try {
192 $script:coverCount++
193 $png = Join-Path $covers "cover-$($script:coverCount).png"
194 $jpg = Join-Path $covers "cover-$($script:coverCount).jpg"
195 SaveSquare $image $png 256 ([System.Drawing.Imaging.ImageFormat]::Png)
196 SaveSquare $image $jpg 160 ([System.Drawing.Imaging.ImageFormat]::Jpeg)
197 $script:cover = $png
198 $script:coverJpg = $jpg
199 } finally {
200 $image.Dispose()
201 }
202 } finally {
203 $stream.Dispose()
204 }
205}
206
207function Emit($value) {
208 [Console]::Out.WriteLine(($value | ConvertTo-Json -Compress))
209 [Console]::Out.Flush()
210}
211
212function HasArtist($session) {
213 try { return "$((Await ($session.TryGetMediaPropertiesAsync()) $Props).Artist)" -ne '' } catch { return $false }
214}
215
216# The session to show and drive: one that plays (music, with an artist, over
217# an app that only says it plays), else the one shown last, else the one
218# Windows calls current.
219function Pick {
220 $all = @($mgr.GetSessions())
221 if ($all.Count -eq 0) { return $null }
222 $playing = @($all | Where-Object { "$($_.GetPlaybackInfo().PlaybackStatus)" -eq 'Playing' })
223 if ($playing.Count -gt 0) {
224 $same = @($playing | Where-Object { $_.SourceAppUserModelId -eq $script:chosen })
225 if ($same.Count -gt 0 -and ($playing.Count -eq 1 -or (HasArtist $same[0]))) { return $same[0] }
226 $music = @($playing | Where-Object { HasArtist $_ })
227 if ($music.Count -gt 0) { return $music[0] }
228 return $playing[0]
229 }
230 $last = @($all | Where-Object { $_.SourceAppUserModelId -eq $script:chosen })
231 if ($last.Count -gt 0) { return $last[0] }
232 $current = $mgr.GetCurrentSession()
233 if ($null -ne $current) { return $current }
234 return $all[0]
235}
236
237function Run($session, [string]$command) {
238 $words = $command.Split(' ')
239 $action = $words[0]
240 switch ($action) {
241 'volume' {
242 $amount = $words[1]
243 [ClaudeDj.Volume]::Set([int]$amount, ($amount.StartsWith('+') -or $amount.StartsWith('-')))
244 return
245 }
246 'mute' { [ClaudeDj.Volume]::Mute($true); return }
247 'unmute' { [ClaudeDj.Volume]::Mute($false); return }
248 'togglemute' { [ClaudeDj.Volume]::Mute(-not [ClaudeDj.Volume]::IsMuted()); return }
249 }
250 if ($null -eq $session) { return }
251 switch ($action) {
252 'play' { $op = $session.TryPlayAsync() }
253 'pause' { $op = $session.TryPauseAsync() }
254 'toggle' { $op = $session.TryTogglePlayPauseAsync() }
255 'next' { $op = $session.TrySkipNextAsync() }
256 'previous' { $op = $session.TrySkipPreviousAsync() }
257 default { return }
258 }
259 [void](Await $op ([bool]))
260}
261
262$last = $null
263$lastError = ''
264while ($true) {
265 try {
266 if ((Test-Path $heartbeat) -and ((Get-Date) - (Get-Item $heartbeat).LastWriteTime).TotalSeconds -gt ${STALE_SECONDS}) {
267 Remove-Item -Recurse -Force $Dir -ErrorAction SilentlyContinue
268 exit 0
269 }
270 $session = Pick
271 foreach ($file in @(Get-ChildItem -Path $commands -Filter '*.cmd' | Sort-Object Name)) {
272 $command = (Get-Content -Raw -Path $file.FullName).Trim()
273 Remove-Item -Force $file.FullName
274 Run $session $command
275 Start-Sleep -Milliseconds 150
276 $session = Pick
277 }
278 $volume = -1
279 $muted = $false
280 try {
281 $volume = [ClaudeDj.Volume]::Level()
282 $muted = [ClaudeDj.Volume]::IsMuted()
283 } catch {}
284 if ($null -eq $session) {
285 $state = [ordered]@{ none = $true; volume = $volume; muted = $muted }
286 $key = "none|$volume|$muted"
287 } else {
288 $script:chosen = $session.SourceAppUserModelId
289 $p = Await ($session.TryGetMediaPropertiesAsync()) $Props
290 $status = "$($session.GetPlaybackInfo().PlaybackStatus)"
291 $t = $session.GetTimelineProperties()
292 $trackKey = "$($session.SourceAppUserModelId)|$($p.Title)|$($p.Artist)|$($p.AlbumTitle)"
293 try { UpdateCover $p $trackKey } catch {}
294 # The position is as of LastUpdatedTime: the mod moves it on from there.
295 $positionAt = 0
296 if ($t.LastUpdatedTime.Year -gt 2000) { $positionAt = $t.LastUpdatedTime.ToUnixTimeMilliseconds() }
297 $state = [ordered]@{
298 app = $session.SourceAppUserModelId
299 title = "$($p.Title)"
300 artist = "$($p.Artist)"
301 album = "$($p.AlbumTitle)"
302 status = $status
303 position = [math]::Round($t.Position.TotalSeconds, 1)
304 positionAt = $positionAt
305 duration = [math]::Round($t.EndTime.TotalSeconds, 1)
306 cover = $script:cover
307 coverJpg = $script:coverJpg
308 volume = $volume
309 muted = $muted
310 }
311 $key = "$trackKey|$status|$($state.duration)|$positionAt|$($script:cover)|$volume|$muted"
312 }
313 if ($key -ne $last) {
314 Emit $state
315 $last = $key
316 }
317 $lastError = ''
318 } catch {
319 $message = "$($_.Exception.Message)"
320 if ($message -ne $lastError) { Emit ([ordered]@{ error = $message }) }
321 $lastError = $message
322 }
323 Start-Sleep -Milliseconds 250
324}
325`
326
327export const windowsWatcherArgv = (script: string, dir: string): string[] => [
328 'powershell.exe',
329 '-NoProfile',
330 '-NonInteractive',
331 '-WindowStyle',
332 'Hidden',
333 '-ExecutionPolicy',
334 'Bypass',
335 '-File',
336 script,
337 '-Dir',
338 dir,
339]
340hooks/spotify.ts 340 lines1// Pure helpers for the Spotify Web API: sign-in (PKCE), what the person
2// typed, what Spotify answered. The hooks module makes the calls.
3
4export const API = 'https://api.spotify.com/v1'
5export const ACCOUNTS = 'https://accounts.spotify.com'
6/** The loopback port the sign-in listens on; the redirect URI the app must register. */
7export const PORT = 43117
8export const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`
9export const SCOPES = [
10 'user-read-private',
11 'user-read-playback-state',
12 'user-modify-playback-state',
13 'user-read-currently-playing',
14 'user-library-modify',
15]
16
17export const KINDS = ['track', 'album', 'artist', 'playlist'] as const
18export type Kind = (typeof KINDS)[number]
19
20export type Tokens = {
21 accessToken: string
22 refreshToken: string
23 /** Milliseconds since the epoch. */
24 expiresAt: number
25}
26
27export type Account = Tokens & {
28 name: string
29 /** `premium`, `free`, ... as Spotify reports it; playback control needs premium. */
30 product: string
31}
32
33/** The account as `$.store` holds it, or undefined for anything else. */
34export function asAccount(value: unknown): Account | undefined {
35 if (typeof value !== 'object' || value === null) return undefined
36 const o = value as Record<string, unknown>
37 const isAccount =
38 typeof o.accessToken === 'string' &&
39 typeof o.refreshToken === 'string' &&
40 typeof o.expiresAt === 'number' &&
41 typeof o.name === 'string' &&
42 typeof o.product === 'string'
43
44 return isAccount ? (o as Account) : undefined
45}
46
47export type Item = { kind: Kind; uri: string; name: string; by: string; extra: string }
48
49export function base64url(bytes: Uint8Array): string {
50 let binary = ''
51 for (const byte of bytes) binary += String.fromCharCode(byte)
52
53 return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
54}
55
56export function randomText(byteCount: number): string {
57 return base64url(crypto.getRandomValues(new Uint8Array(byteCount)))
58}
59
60/** A PKCE verifier and its S256 challenge. */
61export async function pkce(): Promise<{ verifier: string; challenge: string }> {
62 const verifier = randomText(48)
63 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
64
65 return { verifier, challenge: base64url(new Uint8Array(digest)) }
66}
67
68export function authorizeUrl(clientId: string, challenge: string, state: string): string {
69 const query = new URLSearchParams({
70 response_type: 'code',
71 client_id: clientId,
72 scope: SCOPES.join(' '),
73 redirect_uri: REDIRECT_URI,
74 code_challenge_method: 'S256',
75 code_challenge: challenge,
76 state,
77 })
78
79 return `${ACCOUNTS}/authorize?${query.toString()}`
80}
81
82export function codeExchangeBody(clientId: string, code: string, verifier: string): string {
83 return new URLSearchParams({
84 grant_type: 'authorization_code',
85 code,
86 redirect_uri: REDIRECT_URI,
87 client_id: clientId,
88 code_verifier: verifier,
89 }).toString()
90}
91
92export function refreshBody(clientId: string, refreshToken: string): string {
93 return new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId }).toString()
94}
95
96const asObject = (text: string): Record<string, unknown> => {
97 try {
98 const value: unknown = JSON.parse(text)
99 return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
100 } catch {
101 return {}
102 }
103}
104
105/** The token endpoint's answer; a refresh may leave the refresh token out, so the old one stays. */
106export function parseTokens(text: string, now: number, previousRefresh = ''): Tokens | { error: string } {
107 const o = asObject(text)
108 if (typeof o.access_token !== 'string') {
109 const said = typeof o.error_description === 'string' ? o.error_description : typeof o.error === 'string' ? o.error : 'no token'
110 return { error: said }
111 }
112 const seconds = typeof o.expires_in === 'number' ? o.expires_in : 3600
113
114 return {
115 accessToken: o.access_token,
116 refreshToken: typeof o.refresh_token === 'string' ? o.refresh_token : previousRefresh,
117 expiresAt: now + seconds * 1000,
118 }
119}
120
121/** What Spotify's error body says, in words for the person. */
122export function errorText(status: number, text: string): string {
123 const o = asObject(text)
124 const error = typeof o.error === 'object' && o.error !== null ? (o.error as Record<string, unknown>) : {}
125 const reason = typeof error.reason === 'string' ? error.reason : ''
126 const message = typeof error.message === 'string' ? error.message : ''
127 if (reason === 'PREMIUM_REQUIRED') return 'Spotify only lets Premium accounts control playback.'
128 if (reason === 'NO_ACTIVE_DEVICE') return 'Spotify is not open on any device.'
129 if (status === 401) return 'Spotify signed you out; run /spotify login again.'
130 if (status === 403) return `Spotify refused: ${message || 'not allowed'}.`
131 if (status === 429) return 'Spotify says too many requests; try again in a minute.'
132
133 return `Spotify answered ${status}${message === '' ? '' : `: ${message}`}.`
134}
135
136export const isNoActiveDevice = (status: number, text: string): boolean =>
137 status === 404 && text.includes('NO_ACTIVE_DEVICE')
138
139/**
140 * A Spotify URI or open.spotify.com link the person pasted, as a URI;
141 * undefined for anything else (which is then a search).
142 */
143export function spotifyUri(text: string): string | undefined {
144 const trimmed = text.trim()
145 const uri = /^spotify:(track|album|artist|playlist|episode|show):([A-Za-z0-9]{22})$/.exec(trimmed)
146 if (uri) return trimmed
147 const link = /^https?:\/\/open\.spotify\.com\/(?:intl-[a-z-]+\/)?(track|album|artist|playlist|episode|show)\/([A-Za-z0-9]{22})/.exec(trimmed)
148
149 return link ? `spotify:${link[1]}:${link[2]}` : undefined
150}
151
152export const kindOfUri = (uri: string): string => uri.split(':')[1] ?? ''
153
154const names = (list: unknown): string =>
155 Array.isArray(list)
156 ? list
157 .map(one => (typeof one === 'object' && one !== null ? (one as Record<string, unknown>).name : undefined))
158 .filter((name): name is string => typeof name === 'string')
159 .join(', ')
160 : ''
161
162/** The items of a search answer, of one kind, in Spotify's order. */
163export function searchItems(text: string, kind: Kind): Item[] {
164 const o = asObject(text)
165 const group = o[`${kind}s`]
166 const raw = typeof group === 'object' && group !== null ? (group as Record<string, unknown>).items : undefined
167 if (!Array.isArray(raw)) return []
168 const items: Item[] = []
169 for (const entry of raw) {
170 // Spotify leaves holes (null) in playlist results.
171 if (typeof entry !== 'object' || entry === null) continue
172 const e = entry as Record<string, unknown>
173 if (typeof e.uri !== 'string' || typeof e.name !== 'string') continue
174 const album = typeof e.album === 'object' && e.album !== null ? (e.album as Record<string, unknown>) : {}
175 const owner = typeof e.owner === 'object' && e.owner !== null ? (e.owner as Record<string, unknown>) : {}
176 const by =
177 kind === 'playlist'
178 ? typeof owner.display_name === 'string'
179 ? owner.display_name
180 : ''
181 : names(e.artists)
182 const extra = kind === 'track' && typeof album.name === 'string' ? album.name : ''
183 items.push({ kind, uri: e.uri, name: e.name, by, extra })
184 }
185
186 return items
187}
188
189export function itemLine(item: Item): string {
190 const by = item.by === '' ? '' : ` — ${item.by}`
191 const extra = item.extra === '' ? '' : ` (${item.extra})`
192
193 return `${item.name}${by}${extra}`
194}
195
196export type Device = { id: string; name: string; type: string; isActive: boolean; volume: number | null }
197
198export function parseDevices(text: string): Device[] {
199 const raw = asObject(text).devices
200 if (!Array.isArray(raw)) return []
201 const devices: Device[] = []
202 for (const entry of raw) {
203 if (typeof entry !== 'object' || entry === null) continue
204 const e = entry as Record<string, unknown>
205 if (typeof e.id !== 'string' || typeof e.name !== 'string') continue
206 devices.push({
207 id: e.id,
208 name: e.name,
209 type: typeof e.type === 'string' ? e.type : '',
210 isActive: e.is_active === true,
211 volume: typeof e.volume_percent === 'number' ? e.volume_percent : null,
212 })
213 }
214
215 return devices
216}
217
218/** The device to wake when none plays: this computer first, then any. */
219export function pickDevice(devices: Device[]): Device | undefined {
220 return devices.find(one => one.isActive) ?? devices.find(one => one.type === 'Computer') ?? devices[0]
221}
222
223/** The URI and name of what Spotify plays now, from currently-playing. */
224export function currentItem(text: string): { uri: string; name: string } | undefined {
225 const item = asObject(text).item
226 if (typeof item !== 'object' || item === null) return undefined
227 const e = item as Record<string, unknown>
228
229 return typeof e.uri === 'string' && typeof e.name === 'string' ? { uri: e.uri, name: e.name } : undefined
230}
231
232export function parseAccount(text: string): { name: string; product: string } {
233 const o = asObject(text)
234 const name = typeof o.display_name === 'string' && o.display_name !== '' ? o.display_name : typeof o.id === 'string' ? o.id : 'you'
235
236 return { name, product: typeof o.product === 'string' ? o.product : '' }
237}
238
239export type SpotifyCommand =
240 | { verb: 'status' | 'login' | 'logout' | 'like' | 'devices' }
241 | { verb: 'play'; kind: Kind; query: string }
242 | { verb: 'queue'; query: string }
243 | { verb: 'device'; name: string }
244
245/** `/spotify …`: a verb and what follows; bare words play the best matching track. */
246export function spotifyCommandOf(args: string): SpotifyCommand {
247 const text = args.trim()
248 const [first = '', ...rest] = text.split(/\s+/)
249 const word = first.toLowerCase()
250 const tail = rest.join(' ')
251 if (text === '' || word === 'status') return { verb: 'status' }
252 if (word === 'login' || word === 'logout' || word === 'like' || word === 'devices') return { verb: word }
253 if (word === 'queue' || word === 'add') return { verb: 'queue', query: tail }
254 if (word === 'device') return { verb: 'device', name: tail }
255 if (word === 'play') return { verb: 'play', kind: 'track', query: tail }
256 const kind = KINDS.find(one => one === word)
257 if (kind !== undefined && tail !== '') return { verb: 'play', kind, query: tail }
258
259 return { verb: 'play', kind: 'track', query: text }
260}
261
262/** The loopback page that catches Spotify's redirect: prints `{code}` or `{error}` once, then exits. */
263export const CALLBACK_LISTENER = String.raw`param([Parameter(Mandatory = $true)][int]$Port, [Parameter(Mandatory = $true)][string]$State)
264$ErrorActionPreference = 'Stop'
265[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
266$crlf = "$([char]13)$([char]10)"
267function Emit($value) {
268 [Console]::Out.WriteLine(($value | ConvertTo-Json -Compress))
269 [Console]::Out.Flush()
270}
271$listener = New-Object System.Net.Sockets.TcpListener ([System.Net.IPAddress]::Loopback, $Port)
272try { $listener.Start() } catch { Emit ([ordered]@{ error = "port $Port is busy" }); exit 1 }
273$deadline = (Get-Date).AddMinutes(5)
274try {
275 while ((Get-Date) -lt $deadline) {
276 if (-not $listener.Pending()) { Start-Sleep -Milliseconds 200; continue }
277 $client = $listener.AcceptTcpClient()
278 $stream = $client.GetStream()
279 $reader = New-Object System.IO.StreamReader($stream)
280 $first = $reader.ReadLine()
281 $target = if ($null -eq $first) { '' } else { ($first -split ' ')[1] }
282 $query = @{}
283 if ($target -match '\?(.*)$') {
284 foreach ($pair in ($Matches[1] -split '&')) {
285 $kv = $pair -split '=', 2
286 if ($kv.Count -eq 2) { $query[[Uri]::UnescapeDataString($kv[0])] = [Uri]::UnescapeDataString($kv[1].Replace('+', ' ')) }
287 }
288 }
289 $isCallback = "$target".StartsWith('/callback')
290 $isOk = $isCallback -and $query['state'] -eq $State -and $query['code']
291 $said = if ($isOk) { 'Claude DJ is connected to Spotify. You can close this tab.' } else { 'Spotify sign-in did not finish: ' + [System.Net.WebUtility]::HtmlEncode("$($query['error'])") }
292 $html = "<!doctype html><meta charset=utf-8><title>Claude DJ</title><body style='font-family:system-ui,sans-serif;padding:3em'><h2>$said</h2></body>"
293 $bytes = [System.Text.Encoding]::UTF8.GetBytes($html)
294 $head = [System.Text.Encoding]::ASCII.GetBytes("HTTP/1.1 200 OK$($crlf)Content-Type: text/html; charset=utf-8$($crlf)Content-Length: $($bytes.Length)$($crlf)Connection: close$($crlf)$($crlf)")
295 $stream.Write($head, 0, $head.Length)
296 $stream.Write($bytes, 0, $bytes.Length)
297 $stream.Flush()
298 $client.Close()
299 if (-not $isCallback) { continue }
300 if ($isOk) { Emit ([ordered]@{ code = $query['code'] }) }
301 elseif ($query['state'] -ne $State) { Emit ([ordered]@{ error = 'the sign-in answer did not match; run /spotify login again' }) }
302 else { Emit ([ordered]@{ error = "$($query['error'])" }) }
303 exit 0
304 }
305 Emit ([ordered]@{ error = 'nobody signed in within 5 minutes' })
306} finally {
307 $listener.Stop()
308}
309`
310
311export const listenerArgv = (script: string, state: string): string[] => [
312 'powershell.exe',
313 '-NoProfile',
314 '-NonInteractive',
315 '-WindowStyle',
316 'Hidden',
317 '-ExecutionPolicy',
318 'Bypass',
319 '-File',
320 script,
321 '-Port',
322 String(PORT),
323 '-State',
324 state,
325]
326
327/** Opens a URL (https or spotify:) with the default handler; argv, no shell, so `&` stays in it. */
328export const openArgv = (url: string): string[] => ['rundll32.exe', 'url.dll,FileProtocolHandler', url]
329
330/** What the listener printed: the code, or why there is none. */
331export function parseCallback(line: string): { code: string } | { error: string } | undefined {
332 const trimmed = line.trim()
333 if (!trimmed.startsWith('{')) return undefined
334 const o = asObject(trimmed)
335 if (typeof o.code === 'string' && o.code !== '') return { code: o.code }
336 if (typeof o.error === 'string') return { error: o.error === '' ? 'sign-in cancelled' : o.error }
337
338 return undefined
339}
340types/index.d.ts 52 lines1export type Track = {
2 /** The media session's app id, e.g. `chrome.exe` or a packaged app's id. */
3 app: string
4 title: string
5 artist: string
6 album: string
7 /** `Playing`, `Paused`, `Stopped`, ... as the OS reports it. */
8 status: string
9 /** Seconds, as of `positionAt`. */
10 position: number
11 /** When the app last set `position`, ms since the epoch; 0 when unknown. */
12 positionAt: number
13 duration: number
14 /** The cover as a PNG file (for the terminal), or ''. */
15 cover: string
16 /** The cover as a small JPEG file (for the SVG the other surfaces draw), or ''. */
17 coverJpg: string
18}
19
20/** The default output device's volume, as the volume keys change it. */
21export type Volume = {
22 /** 0 to 100. */
23 level: number
24 muted: boolean
25}
26
27export type Player =
28 | { kind: 'starting' }
29 /** The watcher runs; `track` is null when nothing is playing anywhere. */
30 | { kind: 'ready'; track: Track | null; volume: Volume | null }
31 | { kind: 'unavailable'; reason: string }
32
33/** The Spotify account the Web API features run as; null when not signed in. */
34export type SpotifyStatus = {
35 name: string
36 /** `premium`, `free`, ...: playback control needs premium. */
37 product: string
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 'claude-dj': {
43 player: Player
44 spotify: SpotifyStatus | null
45 /** The band hidden with ✕ or /dj hide; kept in `$.store` across sessions. */
46 isHidden: boolean
47 /** Bumped every second while the /music pane is open and music plays, so the progress bar moves. */
48 tick: number
49 }
50 }
51}
52