SLOPSHOPPER

claude-dj

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…

newpanebandguardcommandtoast
v0.4.0MITupdated 2026-10-07Roberdinho/claude-mods/plugins/claude-dj
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-dj
│ ┃ Now playing ✕ › fix the failing auth test and add an audit log call │ ┃ Music control is unavailable: this version │ ┃ drives Windows media sessions only; macOS ⏺ Read(src/auth.ts) │ ┃ and Linux support is coming. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /music │ ⎿ claude-dj: Now playing pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Now playing
Music control is unavailable: this version drives Windows media sessions only; macOS and Linux support is coming.
README

Claude DJ 🎧

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% +  ✕

Use

  • Now-playing band above the prompt, with previous / play-pause / next buttons and mute / volume down / volume up. ✕ hides it (a ♪ 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.
  • Commands (they also work while Claude is busy):
CommandDoes
/djtoggle play/pause
/dj play, /dj pause, /dj next, /dj prevwhat it says
/play, /pause, /next, /prevshort forms
/volshow the volume
/vol 40, /vol +5, /vol -5, /vol up, /vol downset or change the volume
/vol mute, /vol unmute (or /dj mute, /dj vol 40)mute and unmute
/dj hide, /dj showhide or show the band
/musicopen the Now playing pane
  • Music tools for Claude: ask "what's playing?", "skip this one", "turn it down a bit" or "pause the music while you run the tests". Claude gets now_playing, music_control and music_volume tools.

Spotify

Play, pause, skip and volume already work for the Spotify app without setup. Sign in to Spotify for more:

CommandDoes
/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 logoutstatus, 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.

Setup (once)

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).

  1. Go to developer.spotify.com/dashboard, create an app, and pick Web API.
  2. Add the Redirect URI http://127.0.0.1:43117/callback exactly.
  3. Copy the app's Client ID into claude-dj's spotifyClientId setting.
  4. Run /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.

How it works

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.

Settings

In the config menu (or pluginConfigs["claude-dj"].options in settings.json):

OptionDefault
showBandtrue; off shows a ♪ Title — Artist status line entry instead
volumeStep10; 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
musicToolstrue; off hides the music tools from Claude

Requirements

  • Claude Code with function-hook plugins (mods).
  • Windows 10 or 11. macOS and Linux are planned.
  • For the Spotify features: a Spotify Premium account and your own Spotify app (see Setup).

Roadmap

  • macOS (nowplaying-cli) and Linux (playerctl)
  • YouTube Music desktop bridge (th-ch/youtube-music API server, ytmdesktop Companion): search, queue, like
Source 5 files
hooks/register.tsx 864 lines
1import { 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}
864
hooks/dj.ts 199 lines
1import 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}
199
hooks/host.ts 340 lines
1// 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]
340
hooks/spotify.ts 340 lines
1// 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}
340
types/index.d.ts 52 lines
1export 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