SLOPSHOPPER

spotify-mod

Listen to Spotify inside Claude Code — inline mini status bar, sidebar player with album cover, popup window, Spotify login, and voice search

newpanebandspinnerguardcommand
★ 1v0.1.0MITupdated 2026-10-07hemanth/spotify-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spotify-mod
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /spotify ⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ SPOTIFY [MINI] Blinding Lights - The Weeknd ▶ ⏸ ⏭ Voice Sidebar Popup Login │ │ Search: Type song, artist, or Spotify URL and press Enter... ⏎ play │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ SPOTIFY [MINI] Blinding Lights - The Weeknd ▶ ⏸ ⏭ Voice Sidebar Popup Login │ │ Search: Type song, artist, or Spotify URL and press Enter... ⏎ play │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

spotify-mod

Listen to Spotify inside Claude Code with an Inline Mini Status Bar, a Minimal Sidebar Player (rendering real PNG album cover artwork), or a Popup Window, plus Spotify Login and Voice-Enabled Search.

spotify-mod running inside Claude Code

Watch the zoomed feature tour with audio: demo/spotify-mod-demo.mp4

Install

/plugin marketplace add hemanth/spotify-mod
/plugin install spotify-mod@spotify-mod

Or from a clone:

git clone https://github.com/hemanth/spotify-mod
claude --plugin-dir ./spotify-mod

Needs Claude Code 2.1.287+ and macOS.

Usage

/spotify daft punk veridis quo

Searches and starts playback in the default Inline Mini Status Bar (right above your prompt — nothing pops out).

/spotify                         show the inline mini status bar (▶, ⏸, ⏭, Search, Voice, Login)
/spotify <url | uri | search>    search and play track, album, or playlist inline
/spotify login | logout          sign in to your Spotify account to unlock full-length tracks
/spotify voice [spoken query]    voice-enabled search via microphone or spoken phrase
/spotify mini                    use the Inline Mini Status Bar only (no sidebar, no popup)
/spotify panel                   open the Claude Code Sidebar only (real PNG album cover + minimal controls, no popup)
/spotify popup                   open the floating macOS Popup Window only (closes sidebar)
/spotify pause | resume
/spotify next | prev
/spotify hide | show             hide the UI, keep listening
/spotify stop                    stop playback and close player
/spotify status

Display Modes (Mutually Exclusive)

Only one surface is active at a time (Sidebar and Popup Window never open together):

  1. Inline Mini Status Bar (mini, default) — Inline AbovePrompt bar (SPOTIFY [MINI] Track - Artist [▶] [⏸] [⏭] [Voice] [Sidebar] [Popup] [Login] + inline Search input). Background audio runs headlessly with zero popout windows.
  2. Sidebar (side-panel) — Opens only the Claude Code sidebar (Pane) rendering the actual high-resolution album cover PNG image (/tmp/claude-spotify-mod-cover.png) via Claude Code's Image component, track title/artist, ⏮ / ▶ / ⏸ / ⏭ / Voice / Login, and a Search input. No floating popup window is shown.
  3. Popup Window (popup) — Opens only the compact floating macOS window and closes the Claude Code sidebar.

Spotify Login

Run /spotify login or click Login in the Mini Status Bar, Sidebar, or Popup Window to sign in to your Spotify account. Your session cookies (sp_dc / sp_key) persist in the native WKWebsiteDataStore, allowing Spotify Web embeds to play full-length songs instead of 30-second previews. Once login finishes, the auth sheet closes automatically and returns to your active mode. Run /spotify logout anytime to clear cookies.

Voice-Enabled Search

Trigger voice search in three ways:

  • Click Voice in the Inline Mini Status Bar or Sidebar (uses native on-device SFSpeechRecognizer + AVAudioEngine microphone transcription)
  • Click Voice in the Popup Window header
  • Run /spotify voice (or /spotify voice play midnight city)

Development

claude plugin validate --strict .
claude plugin test .
swiftc -O native/SpotifyMiniPlayer.swift -o bin/spotify-pip -framework Cocoa -framework WebKit -framework Speech -framework AVFoundation

License

MIT (c) Hemanth.HM

Source 1 files
hooks/register.js 1594 lines
1/**
2 * Spotify Mod — Claude Code Plugin
3 *
4 * Minimal Spotify player with three mutually exclusive modes:
5 *   1. "mini" (default)      — Inline status bar above the prompt (no sidebar, no popup window).
6 *   2. "side-panel"          — Claude Code sidebar rendering the real album cover PNG image and minimal controls (no popup window).
7 *   3. "popup"               — Native macOS floating popup window only (closes the sidebar).
8 *
9 * Supports Spotify Login (/spotify login) in a persistent WebKit cookie store to unlock full-length tracks.
10 */
11
12const PANE_ID = 'spotify-mod';
13const STATE_FILE_PATH = '/tmp/claude-spotify-mod-state.json';
14const COVER_PNG_FILE_PATH = '/tmp/claude-spotify-mod-cover.png';
15
16const CURATED_TRACKS = [
17  {
18    id: '0VjIjW4GlUZAMYd2vXMi3b',
19    kind: 'track',
20    title: 'Blinding Lights',
21    artist: 'The Weeknd',
22    category: 'Synthwave'
23  },
24  {
25    id: '4cOdK2wGLETKBW3PvgPWqT',
26    kind: 'track',
27    title: 'Veridis Quo',
28    artist: 'Daft Punk',
29    category: 'Electronic'
30  },
31  {
32    id: '5ChkMS8OtdzJeqyybCc9R5',
33    kind: 'track',
34    title: 'Midnight City',
35    artist: 'M83',
36    category: 'Synthpop'
37  },
38  {
39    id: '37i9dQZF1DWWQRwui0ExPn',
40    kind: 'playlist',
41    title: 'lofi beats',
42    artist: 'Spotify',
43    category: 'Focus'
44  },
45  {
46    id: '37i9dQZF1DX5trt9i14X7j',
47    kind: 'playlist',
48    title: 'Coding Mode',
49    artist: 'Spotify',
50    category: 'Deep Work'
51  }
52];
53
54let pluginRootPath = '';
55let currentTrack = { ...CURATED_TRACKS[0] };
56let isPlaying = false;
57let hasActiveTrack = false;
58let isUiHidden = false;
59let isVoiceListening = false;
60let isLoggedIn = false;
61let coverGeneration = 1;
62let lastVoiceTranscript = '';
63
64// Mutually exclusive controllerMode: 'mini' | 'side-panel' | 'popup'
65let controllerMode = 'mini';
66let screenPosition = 'top-right';
67let customCoords = '';
68let sizePreset = 'mini';
69let opacity = 0.96;
70let uiPlacement = 'both';
71let paneColumns = 38;
72let playlist = [...CURATED_TRACKS];
73
74export function extractSpotifyTarget(raw) {
75  if (!raw || typeof raw !== 'string') return null;
76  const trimmed = raw.trim();
77  if (!trimmed) return null;
78
79  const uriMatch = trimmed.match(/^spotify:(track|playlist|album|artist|episode):([A-Za-z0-9]{22})$/i);
80  if (uriMatch) {
81    return {
82      kind: uriMatch[1].toLowerCase(),
83      id: uriMatch[2]
84    };
85  }
86
87  const urlMatch = trimmed.match(
88    /open\.spotify\.com\/(?:intl-[a-z]{2}\/)?(?:embed\/)?(track|playlist|album|artist|episode)\/([A-Za-z0-9]{22})/i
89  );
90  if (urlMatch) {
91    return {
92      kind: urlMatch[1].toLowerCase(),
93      id: urlMatch[2]
94    };
95  }
96
97  if (/^[A-Za-z0-9]{22}$/.test(trimmed) && /[0-9]/.test(trimmed) && /[A-Za-z]/.test(trimmed)) {
98    return {
99      kind: 'track',
100      id: trimmed
101    };
102  }
103
104  return null;
105}
106
107export function normalizeControllerMode(raw) {
108  if (!raw || typeof raw !== 'string') return 'mini';
109  const v = raw.trim().toLowerCase();
110  if (
111    v === 'side-panel' ||
112    v === 'panel' ||
113    v === 'side' ||
114    v === 'sidebar' ||
115    v === 'pull' ||
116    v === 'pull-panel' ||
117    v === 'drawer' ||
118    v === 'full'
119  ) {
120    return 'side-panel';
121  }
122  if (
123    v === 'popup' ||
124    v === 'pop-up' ||
125    v === 'window' ||
126    v === 'compact' ||
127    v === 'card' ||
128    v === 'compact-card' ||
129    v === 'float'
130  ) {
131    return 'popup';
132  }
133  return 'mini';
134}
135
136export function normalizePosition(raw) {
137  if (!raw || typeof raw !== 'string') return 'top-right';
138  const v = raw.trim().toLowerCase();
139  const map = {
140    tl: 'top-left',
141    'top-left': 'top-left',
142    topleft: 'top-left',
143    nw: 'top-left',
144    tc: 'top-center',
145    'top-center': 'top-center',
146    top: 'top-center',
147    north: 'top-center',
148    tr: 'top-right',
149    'top-right': 'top-right',
150    topright: 'top-right',
151    ne: 'top-right',
152    l: 'left-side',
153    left: 'left-side',
154    'left-side': 'left-side',
155    west: 'left-side',
156    'dock-left': 'left-side',
157    'pull-left': 'left-side',
158    c: 'center',
159    center: 'center',
160    middle: 'center',
161    r: 'right-side',
162    right: 'right-side',
163    'right-side': 'right-side',
164    east: 'right-side',
165    'dock-right': 'right-side',
166    'pull-right': 'right-side',
167    bl: 'bottom-left',
168    'bottom-left': 'bottom-left',
169    bottomleft: 'bottom-left',
170    sw: 'bottom-left',
171    bc: 'bottom-center',
172    'bottom-center': 'bottom-center',
173    bottom: 'bottom-center',
174    south: 'bottom-center',
175    br: 'bottom-right',
176    'bottom-right': 'bottom-right',
177    bottomright: 'bottom-right',
178    se: 'bottom-right',
179    custom: 'custom'
180  };
181  if (map[v]) return map[v];
182  if (/^\d+\s*,\s*\d+/.test(v)) return 'custom';
183  return 'top-right';
184}
185
186function hashTrackSeed(track) {
187  const s = ((track && track.title) || 'Spotify') + ':' + ((track && track.artist) || '');
188  let h = 2166136261;
189  for (let i = 0; i < s.length; i += 1) {
190    h ^= s.charCodeAt(i);
191    h = Math.imul(h, 16777619);
192  }
193  return h >>> 0;
194}
195
196function buildAlbumCoverSvg(track) {
197  const seed = hashTrackSeed(track);
198  const colors = ['#1DB954', '#3B82F6', '#EC4899', '#F59E0B', '#14B8A6'];
199  const accent = colors[seed % colors.length];
200  return (
201    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120">' +
202    '<rect x="2" y="2" width="116" height="116" rx="10" fill="#0B0F14" stroke="' +
203    accent +
204    '" stroke-width="2"/>' +
205    '<circle cx="60" cy="60" r="42" fill="#141A22" stroke="' +
206    accent +
207    '" stroke-width="2"/>' +
208    '<circle cx="60" cy="60" r="28" fill="none" stroke="#263238" stroke-width="1.5"/>' +
209    '<circle cx="60" cy="60" r="12" fill="' +
210    accent +
211    '"/>' +
212    '<circle cx="60" cy="60" r="4" fill="#0B0F14"/>' +
213    '</svg>'
214  );
215}
216
217async function persistSettings($) {
218  await $.store.set('currentTrack', currentTrack);
219  await $.store.set('controllerMode', controllerMode);
220  await $.store.set('screenPosition', screenPosition);
221  await $.store.set('customCoords', customCoords);
222  await $.store.set('sizePreset', sizePreset);
223  await $.store.set('opacity', opacity);
224  await $.store.set('uiPlacement', uiPlacement);
225  await $.store.set('paneColumns', paneColumns);
226  await $.store.set('playlist', playlist);
227  await $.store.set('isLoggedIn', isLoggedIn);
228}
229
230async function loadSavedSettings($) {
231  const savedTrack = await $.store.get('currentTrack');
232  if (savedTrack && typeof savedTrack === 'object' && savedTrack.id) {
233    currentTrack = savedTrack;
234  }
235  const savedMode = await $.store.get('controllerMode');
236  if (typeof savedMode === 'string' && savedMode) {
237    controllerMode = normalizeControllerMode(savedMode);
238  }
239  const savedPos = await $.store.get('screenPosition');
240  if (typeof savedPos === 'string' && savedPos) {
241    screenPosition = savedPos;
242  }
243  const savedCustom = await $.store.get('customCoords');
244  if (typeof savedCustom === 'string') {
245    customCoords = savedCustom;
246  }
247  const savedSize = await $.store.get('sizePreset');
248  if (typeof savedSize === 'string' && savedSize) {
249    sizePreset = savedSize;
250  }
251  const savedOpacity = await $.store.get('opacity');
252  if (typeof savedOpacity === 'number') {
253    opacity = savedOpacity;
254  }
255  const savedPlacement = await $.store.get('uiPlacement');
256  if (typeof savedPlacement === 'string' && savedPlacement) {
257    uiPlacement = savedPlacement;
258  }
259  const savedCols = await $.store.get('paneColumns');
260  if (typeof savedCols === 'number') {
261    paneColumns = savedCols;
262  }
263  const savedPlaylist = await $.store.get('playlist');
264  if (Array.isArray(savedPlaylist) && savedPlaylist.length > 0) {
265    playlist = savedPlaylist;
266  }
267  const savedLoggedIn = await $.store.get('isLoggedIn');
268  if (typeof savedLoggedIn === 'boolean') {
269    isLoggedIn = savedLoggedIn;
270  }
271}
272
273export function cleanMusicQuery(raw) {
274  let q = String(raw || '').trim();
275  const prefixes = [
276    'how about ',
277    'what about ',
278    'can you play ',
279    'could you play ',
280    'please play ',
281    'play some ',
282    'play ',
283    'put on ',
284    'listen to ',
285    'search for ',
286    'search ',
287    'find ',
288    'i want to hear ',
289    "let's hear "
290  ];
291  const lower = q.toLowerCase();
292  for (const prefix of prefixes) {
293    if (lower.startsWith(prefix)) {
294      q = q.slice(prefix.length);
295      break;
296    }
297  }
298  q = q.replace(/^[?!.,;:\s]+|[?!.,;:\s]+$/g, '');
299  return q || String(raw || '').trim();
300}
301
302async function resolveSpotifyMetadata($, target, fallbackTitle, fallbackArtist, fallbackAudioUrl, fallbackArtworkUrl) {
303  const kind = (target && target.kind) || 'track';
304  const id = (target && target.id) || CURATED_TRACKS[0].id;
305
306  if (fallbackAudioUrl && fallbackTitle) {
307    return {
308      id,
309      kind,
310      title: fallbackTitle,
311      artist: fallbackArtist || 'Spotify',
312      audioUrl: fallbackAudioUrl,
313      artworkUrl: fallbackArtworkUrl || ''
314    };
315  }
316
317  const isSpotifyId = /^[A-Za-z0-9]{22}$/.test(id);
318  if (isSpotifyId) {
319    const webUrl = 'https://open.spotify.com/' + kind + '/' + id;
320    const oembedUrl = 'https://open.spotify.com/oembed?url=' + encodeURIComponent(webUrl);
321
322    try {
323      const res = await $.http.fetch(oembedUrl);
324      if (res && res.ok && res.text) {
325        const data = JSON.parse(res.text);
326        if (data && (data.title || data.author_name)) {
327          return {
328            id,
329            kind,
330            title: data.title || fallbackTitle || 'Spotify ' + kind + ' (' + id + ')',
331            artist: data.author_name || fallbackArtist || 'Spotify',
332            audioUrl: fallbackAudioUrl || '',
333            artworkUrl: data.thumbnail_url || fallbackArtworkUrl || ''
334          };
335        }
336      }
337    } catch {
338      // Offline or test environment fallback
339    }
340  }
341
342  const curated = CURATED_TRACKS.find((t) => t.id === id);
343  if (curated) {
344    return { ...curated, audioUrl: fallbackAudioUrl || '', artworkUrl: fallbackArtworkUrl || '' };
345  }
346  return {
347    id,
348    kind,
349    title: fallbackTitle || 'Spotify ' + kind + ' (' + id + ')',
350    artist: fallbackArtist || 'Spotify',
351    audioUrl: fallbackAudioUrl || '',
352    artworkUrl: fallbackArtworkUrl || ''
353  };
354}
355
356export async function searchSpotifyTracks($, query) {
357  const cleaned = cleanMusicQuery(query);
358  if (!cleaned) return [];
359  const lower = cleaned.toLowerCase();
360
361  const localMatches = playlist.filter(
362    (t) =>
363      t.title.toLowerCase().includes(lower) ||
364      (t.artist && t.artist.toLowerCase().includes(lower)) ||
365      (t.category && t.category.toLowerCase().includes(lower))
366  );
367  const exactCurated = CURATED_TRACKS.find((t) => t.title.toLowerCase() === lower);
368  const searchTerm =
369    exactCurated && exactCurated.artist && !exactCurated.artist.toLowerCase().includes('spotify')
370      ? exactCurated.title + ' ' + exactCurated.artist
371      : cleaned;
372
373  try {
374    const searchUrl =
375      'https://itunes.apple.com/search?media=music&entity=song&limit=8&term=' +
376      encodeURIComponent(searchTerm);
377    const res = await $.http.fetch(searchUrl);
378    if (res && res.ok && res.text) {
379      const parsed = JSON.parse(res.text);
380      if (parsed && Array.isArray(parsed.results) && parsed.results.length > 0) {
381        const tracks = parsed.results
382          .filter((item) => item && (item.trackName || item.collectionName))
383          .map((item) => ({
384            id: item.trackId ? String(item.trackId) : CURATED_TRACKS[0].id,
385            kind: 'track',
386            title: item.trackName || item.collectionName || cleaned,
387            artist: item.artistName || 'Spotify',
388            category: item.primaryGenreName || 'Search',
389            audioUrl: item.previewUrl || '',
390            artworkUrl: item.artworkUrl100 || ''
391          }));
392        if (tracks.length > 0) {
393          return tracks;
394        }
395      }
396
397      const results = [];
398      const seen = new Set();
399      const trackRegex = /spotify:(track|playlist):([A-Za-z0-9]{22})/g;
400      let match = trackRegex.exec(res.text);
401      while (match && results.length < 6) {
402        const kind = match[1].toLowerCase();
403        const id = match[2];
404        if (!seen.has(id)) {
405          seen.add(id);
406          results.push({
407            id,
408            kind,
409            title: cleaned,
410            artist: 'Spotify Search'
411          });
412        }
413        match = trackRegex.exec(res.text);
414      }
415      if (results.length > 0) {
416        return results;
417      }
418    }
419  } catch {
420    // Ignore and fall back to local curated catalog
421  }
422
423  if (localMatches.length > 0) {
424    return localMatches;
425  }
426
427  return [
428    {
429      id: CURATED_TRACKS[0].id,
430      kind: 'track',
431      title: cleaned,
432      artist: 'Spotify Search',
433      query: cleaned
434    },
435    ...CURATED_TRACKS.slice(0, 3)
436  ];
437}
438
439async function runNativePlayerCommand($, args) {
440  const binPath = pluginRootPath + '/bin/spotify-pip';
441  try {
442    const res = await $.process.run([binPath, ...args]);
443    if (res && res.stdout) {
444      try {
445        const parsed = JSON.parse(String(res.stdout).trim());
446        if (parsed && typeof parsed === 'object') {
447          if (parsed.artworkUrl && !currentTrack.artworkUrl) {
448            currentTrack = { ...currentTrack, artworkUrl: parsed.artworkUrl };
449          }
450          if (typeof parsed.artworkGeneration === 'number') {
451            coverGeneration = parsed.artworkGeneration;
452          }
453          if (typeof parsed.isLoggedIn === 'boolean') {
454            isLoggedIn = parsed.isLoggedIn;
455          }
456        }
457      } catch {
458        // Ignore non-JSON output
459      }
460    }
461    return res;
462  } catch {
463    return { exitCode: 1, stdout: '', stderr: '' };
464  }
465}
466
467async function syncFromNativeStateFile($) {
468  try {
469    const raw = await $.fs.read(STATE_FILE_PATH);
470    if (!raw) return;
471    const parsed = JSON.parse(raw);
472    if (parsed && typeof parsed === 'object') {
473      if (parsed.position) {
474        screenPosition = parsed.position;
475      }
476      if (parsed.controllerMode) {
477        controllerMode = normalizeControllerMode(parsed.controllerMode);
478      }
479      if (typeof parsed.customX === 'number' && typeof parsed.customY === 'number') {
480        customCoords = Math.round(parsed.customX) + ',' + Math.round(parsed.customY);
481      }
482      if (typeof parsed.isPaused === 'boolean') {
483        isPlaying = !parsed.shouldQuit && !parsed.isPaused;
484      }
485      if (typeof parsed.isLoggedIn === 'boolean') {
486        isLoggedIn = parsed.isLoggedIn;
487      }
488      if (typeof parsed.artworkGeneration === 'number') {
489        coverGeneration = parsed.artworkGeneration;
490      }
491      if (typeof parsed.title === 'string' && parsed.title.trim()) {
492        currentTrack = {
493          ...currentTrack,
494          id: parsed.trackId || currentTrack.id,
495          kind: parsed.kind || currentTrack.kind || 'track',
496          title: parsed.title.trim(),
497          artist: parsed.artist || currentTrack.artist || 'Spotify',
498          audioUrl: parsed.audioUrl || currentTrack.audioUrl || '',
499          artworkUrl: parsed.artworkUrl || currentTrack.artworkUrl || ''
500        };
501      }
502      if (typeof parsed.lastVoiceQuery === 'string' && parsed.lastVoiceQuery.trim()) {
503        lastVoiceTranscript = parsed.lastVoiceQuery.trim();
504      }
505    }
506  } catch {
507    // Ignore missing state file before first launch
508  }
509}
510
511async function openPlayerPane($, { focus = true } = {}) {
512  await $.ui.open({
513    id: PANE_ID,
514    title: 'Spotify',
515    ...(focus ? { focus: true } : {}),
516    closeOnEscape: true,
517    columns: paneColumns
518  });
519}
520
521// Mutually exclusive controller mode switching:
522//   - 'mini': inline status bar only (closes Sidebar pane, hides macOS popup window)
523//   - 'side-panel': Sidebar pane only (opens Sidebar pane, hides macOS popup window)
524//   - 'popup': macOS popup window only (closes Sidebar pane, shows macOS popup window)
525async function setControllerConfiguration($, modeInput, requestedPos) {
526  const nextMode = normalizeControllerMode(modeInput);
527  controllerMode = nextMode;
528  hasActiveTrack = true;
529  isUiHidden = false;
530
531  if (nextMode === 'mini') {
532    sizePreset = 'mini';
533    if (requestedPos) {
534      screenPosition = normalizePosition(requestedPos);
535    }
536    await $.ui.close({ id: PANE_ID });
537  } else if (nextMode === 'side-panel') {
538    sizePreset = 'sidebar';
539    screenPosition = requestedPos ? normalizePosition(requestedPos) : 'right-side';
540    if (screenPosition !== 'right-side' && screenPosition !== 'left-side') {
541      screenPosition = 'right-side';
542    }
543    if (!currentTrack.artworkUrl) {
544      const meta = await resolveSpotifyMetadata(
545        $,
546        { kind: currentTrack.kind || 'track', id: currentTrack.id },
547        currentTrack.title,
548        currentTrack.artist,
549        currentTrack.audioUrl,
550        currentTrack.artworkUrl
551      );
552      currentTrack = { ...currentTrack, ...meta };
553    }
554    await openPlayerPane($, { focus: false });
555  } else {
556    // 'popup' mode: close the Claude Code sidebar so ONLY the popup window is shown
557    sizePreset = 'compact';
558    if (requestedPos) {
559      screenPosition = normalizePosition(requestedPos);
560    }
561    await $.ui.close({ id: PANE_ID });
562  }
563
564  const posArg = screenPosition === 'custom' && customCoords ? customCoords : screenPosition;
565  const modeArgs = [
566    'mode',
567    controllerMode,
568    '--position',
569    posArg,
570    '--size',
571    sizePreset,
572    '--title',
573    currentTrack.title || 'Spotify',
574    '--artist',
575    currentTrack.artist || 'Spotify'
576  ];
577  if (currentTrack.artworkUrl) {
578    modeArgs.push('--artwork-url', currentTrack.artworkUrl);
579  }
580  await runNativePlayerCommand($, modeArgs);
581  await syncFromNativeStateFile($);
582  await persistSettings($);
583  $.ui.invalidate('ui.render');
584  return { controllerMode, screenPosition, sizePreset };
585}
586
587async function openSpotifyLogin($) {
588  hasActiveTrack = true;
589  isUiHidden = false;
590  await runNativePlayerCommand($, ['login']);
591  await syncFromNativeStateFile($);
592  await persistSettings($);
593  $.ui.toast('Opened Spotify Login window. Sign in to unlock full-length tracks.');
594  $.ui.invalidate('ui.render');
595}
596
597async function logoutSpotify($) {
598  isLoggedIn = false;
599  await runNativePlayerCommand($, ['logout']);
600  await syncFromNativeStateFile($);
601  await persistSettings($);
602  $.ui.toast('Logged out of Spotify.');
603  $.ui.invalidate('ui.render');
604}
605
606async function playSpotifyTarget($, rawTarget, requestedPos, requestedModeOrSize) {
607  if (requestedPos) {
608    const norm = normalizePosition(requestedPos);
609    screenPosition = norm;
610    if (norm === 'custom') {
611      customCoords = String(requestedPos).trim();
612    }
613  }
614  if (requestedModeOrSize) {
615    const m = String(requestedModeOrSize).trim().toLowerCase();
616    if (
617      m === 'mini' ||
618      m === 'tiny' ||
619      m === 'panel' ||
620      m === 'side-panel' ||
621      m === 'sidebar' ||
622      m === 'pull' ||
623      m === 'popup' ||
624      m === 'pop-up' ||
625      m === 'window' ||
626      m === 'card' ||
627      m === 'compact'
628    ) {
629      controllerMode = normalizeControllerMode(m);
630      sizePreset =
631        controllerMode === 'side-panel'
632          ? 'sidebar'
633          : controllerMode === 'mini'
634            ? 'mini'
635            : 'compact';
636    } else {
637      sizePreset = m;
638    }
639  }
640
641  const trimmed = String(rawTarget || '').trim();
642  let parsedTarget = extractSpotifyTarget(trimmed);
643  let resolvedMeta = null;
644
645  const existingItem = playlist.find((item) => item.id === trimmed);
646  if (existingItem && !parsedTarget) {
647    parsedTarget = { kind: existingItem.kind || 'track', id: existingItem.id };
648    resolvedMeta = existingItem;
649  }
650
651  if (!parsedTarget && trimmed) {
652    const found = await searchSpotifyTracks($, trimmed);
653    if (found.length > 0) {
654      parsedTarget = {
655        kind: found[0].kind || 'track',
656        id: found[0].id
657      };
658      resolvedMeta = found[0];
659      const merged = [...found];
660      for (const oldItem of playlist) {
661        if (!merged.some((m) => m.id === oldItem.id) && merged.length < 18) {
662          merged.push(oldItem);
663        }
664      }
665      playlist = merged;
666    }
667  }
668
669  if (!parsedTarget) {
670    parsedTarget = {
671      kind: currentTrack.kind || 'track',
672      id: currentTrack.id || CURATED_TRACKS[0].id
673    };
674    resolvedMeta = currentTrack;
675  }
676
677  const meta = await resolveSpotifyMetadata(
678    $,
679    parsedTarget,
680    resolvedMeta ? resolvedMeta.title : '',
681    resolvedMeta ? resolvedMeta.artist : '',
682    resolvedMeta ? resolvedMeta.audioUrl : '',
683    resolvedMeta ? resolvedMeta.artworkUrl : ''
684  );
685  currentTrack = meta;
686  isPlaying = true;
687  hasActiveTrack = true;
688  coverGeneration += 1;
689
690  if (!playlist.some((item) => item.id === meta.id)) {
691    playlist = [meta, ...playlist.slice(0, 14)];
692  }
693
694  const posArg = screenPosition === 'custom' && customCoords ? customCoords : screenPosition;
695  const cmdArgs = [
696    'play',
697    meta.id,
698    '--kind',
699    meta.kind || 'track',
700    '--mode',
701    controllerMode,
702    '--position',
703    posArg,
704    '--size',
705    sizePreset,
706    '--title',
707    meta.title,
708    '--artist',
709    meta.artist || 'Spotify',
710    '--opacity',
711    String(opacity)
712  ];
713  if (meta.audioUrl) {
714    cmdArgs.push('--audio-url', meta.audioUrl);
715  }
716  if (meta.artworkUrl) {
717    cmdArgs.push('--artwork-url', meta.artworkUrl);
718  }
719  await runNativePlayerCommand($, cmdArgs);
720
721  // Sync back any resolved artworkGeneration or login state written by native binary
722  await syncFromNativeStateFile($);
723
724  await persistSettings($);
725  $.ui.invalidate('ui.render');
726  return currentTrack;
727}
728
729async function runVoiceSearch($, spokenTextOverride) {
730  isVoiceListening = true;
731  hasActiveTrack = true;
732  $.ui.invalidate('ui.render');
733
734  let transcript = String(spokenTextOverride || '').trim();
735
736  if (!transcript) {
737    $.ui.toast('Listening for voice search... Speak a song or artist.');
738    const res = await runNativePlayerCommand($, ['voice', '--listen', '4']);
739    const out = String((res && res.stdout) || '').trim();
740    if (out) {
741      try {
742        const parsed = JSON.parse(out);
743        transcript = String(parsed.lastVoiceQuery || parsed.transcript || '').trim();
744      } catch {
745        transcript = out.split('\n').pop() || '';
746      }
747    }
748  }
749
750  isVoiceListening = false;
751
752  if (!transcript) {
753    $.ui.toast('No voice query captured. Click Voice or run /spotify voice <query>.');
754    $.ui.invalidate('ui.render');
755    return null;
756  }
757
758  lastVoiceTranscript = transcript;
759  const meta = await playSpotifyTarget($, transcript, null, null);
760  $.ui.toast('Voice matched "' + meta.title + '" - ' + (meta.artist || 'Spotify'));
761  return { transcript, track: meta };
762}
763
764async function movePlayerPosition($, newPos, newSizeOrMode) {
765  const raw = String(newPos || '').trim();
766  if (raw === 'pane' || raw === 'band' || raw === 'both' || raw === 'spinner') {
767    uiPlacement = raw;
768    await persistSettings($);
769    $.ui.invalidate('ui.render');
770    return { kind: 'ui', placement: uiPlacement };
771  }
772
773  const norm = normalizePosition(raw);
774  screenPosition = norm;
775  if (norm === 'custom') {
776    customCoords = raw;
777  }
778  if (newSizeOrMode) {
779    const token = String(newSizeOrMode).trim().toLowerCase();
780    if (token === 'mini' || token === 'side-panel' || token === 'panel' || token === 'sidebar' || token === 'popup') {
781      controllerMode = normalizeControllerMode(token);
782      sizePreset = controllerMode === 'side-panel' ? 'sidebar' : controllerMode === 'mini' ? 'mini' : 'compact';
783    } else {
784      sizePreset = token;
785    }
786  }
787
788  const posArg = screenPosition === 'custom' && customCoords ? customCoords : screenPosition;
789  await runNativePlayerCommand($, ['position', posArg, sizePreset, '--mode', controllerMode]);
790  await persistSettings($);
791  $.ui.invalidate('ui.render');
792  return { kind: 'screen', position: screenPosition, sizePreset, controllerMode };
793}
794
795const SPOTIFY_HELP = [
796  '/spotify                        show inline mini status bar (default: no sidebar, no popup)',
797  '/spotify <url|uri|search>       search and play track, album, or playlist',
798  '/spotify voice [spoken query]   voice-enabled search via microphone or spoken phrase',
799  '/spotify login | logout         sign in to Spotify account for full-length tracks',
800  '/spotify mini                   use inline mini status bar only',
801  '/spotify panel                  open sidebar player only (no popup window)',
802  '/spotify popup                  open floating popup window only (closes sidebar)',
803  '/spotify pause | resume | next | prev | stop | status'
804].join('\n');
805
806async function stopPlayback($) {
807  isPlaying = false;
808  hasActiveTrack = false;
809  await runNativePlayerCommand($, ['stop']);
810  await $.ui.close({ id: PANE_ID });
811  $.ui.invalidate('ui.render');
812}
813
814async function stepPlaylist($, delta) {
815  if (playlist.length === 0) return currentTrack;
816  const idx = playlist.findIndex((item) => item.id === currentTrack.id);
817  const nextIdx = idx === -1 ? 0 : (idx + delta + playlist.length) % playlist.length;
818  const nextItem = playlist[nextIdx];
819  return playSpotifyTarget($, nextItem.id, null, null);
820}
821
822function describeConfig() {
823  const modeLabel =
824    controllerMode === 'mini'
825      ? 'Inline Mini Bar'
826      : controllerMode === 'side-panel'
827        ? 'Sidebar'
828        : 'Popup Window';
829  return (
830    'Mode: ' +
831    modeLabel +
832    ' (' +
833    controllerMode +
834    ') | Position: ' +
835    screenPosition +
836    ' [' +
837    sizePreset +
838    '] | Account: ' +
839    (isLoggedIn ? 'Logged In' : 'Guest') +
840    (lastVoiceTranscript ? ' | Last Voice: "' + lastVoiceTranscript + '"' : '')
841  );
842}
843
844export function register(on) {
845  on('session.start', async ($, e, next) => {
846    await loadSavedSettings($);
847    pluginRootPath = $.plugin.root;
848
849    try {
850      await $.command.register({
851        name: 'spotify',
852        description:
853          'Spotify Mod: inline mini bar, sidebar with album cover image, popup player, and Spotify Login',
854        argumentHint:
855          '[play <url|search> | voice [query] | login | logout | mini | panel | popup | pause | resume | next | prev | stop | status]',
856        immediate: true
857      });
858    } catch {
859      // Ignore duplicate command registration on reload
860    }
861
862    try {
863      await $.tool.register({
864        name: 'spotify_player',
865        description:
866          'Play songs or playlists on Spotify, run voice-enabled music search, login to Spotify, or switch between inline mini bar, sidebar, and popup window.',
867        inputSchema: {
868          type: 'object',
869          properties: {
870            action: {
871              type: 'string',
872              description:
873                'play, search, voice, login, logout, mini, panel, popup, mode, position, pause, resume, next, prev, stop, or status'
874            },
875            queryOrUrl: {
876              type: 'string',
877              description: 'Spotify URL, URI, 22-character ID, search query, or spoken voice transcript'
878            },
879            mode: {
880              type: 'string',
881              description: 'mini (inline bar only), side-panel (sidebar only), or popup (popup window only)'
882            },
883            position: {
884              type: 'string',
885              description:
886                'top-right, top-left, bottom-right, bottom-left, right-side, left-side, top-center, bottom-center, center, or custom x,y,w,h'
887            },
888            size: {
889              type: 'string',
890              description: 'mini, compact, large, or sidebar'
891            }
892          },
893          required: ['action']
894        }
895      });
896    } catch {
897      // Ignore duplicate tool registration on reload
898    }
899
900    return next(e);
901  });
902
903  on('classic.SessionStart', { source: 'clear' }, async ($, e, next) => {
904    try {
905      await loadSavedSettings($);
906    } catch {
907      // Non-fatal
908    }
909    return next(e);
910  });
911
912  on('command.run', { command: 'spotify' }, async ($, e) => {
913    const rawArgs = String(e.args || '').trim();
914    await syncFromNativeStateFile($);
915
916    const [first = '', ...rest] = rawArgs.split(/\s+/).filter(Boolean);
917    const sub = first.toLowerCase();
918    const restText = rest.join(' ');
919
920    if (!sub || sub === 'show') {
921      isUiHidden = false;
922      hasActiveTrack = true;
923      if (controllerMode === 'side-panel') {
924        await openPlayerPane($, { focus: !sub });
925      } else {
926        await $.ui.close({ id: PANE_ID });
927      }
928      $.ui.invalidate('ui.render');
929      return {};
930    }
931    if (sub === 'help') {
932      return { text: SPOTIFY_HELP };
933    }
934    if (sub === 'login' || sub === 'auth' || sub === 'signin') {
935      await openSpotifyLogin($);
936      return {
937        text: 'Opened Spotify Login window. Sign in to your Spotify account to play full-length songs instead of 30s previews.'
938      };
939    }
940    if (sub === 'logout' || sub === 'signout') {
941      await logoutSpotify($);
942      return {
943        text: 'Logged out of Spotify account.'
944      };
945    }
946    if (sub === 'mini' || sub === 'tiny') {
947      await setControllerConfiguration($, 'mini', rest[0] || null);
948      return {
949        text: 'Switched Spotify Mod to inline Mini Bar.'
950      };
951    }
952    if (sub === 'panel' || sub === 'side-panel' || sub === 'sidebar' || sub === 'pull') {
953      const side = rest[0] ? normalizePosition(rest[0]) : 'right-side';
954      const cfg = await setControllerConfiguration($, 'side-panel', side);
955      return {
956        text: 'Opened Spotify Sidebar (' + cfg.screenPosition + ' [' + cfg.sizePreset + ']).'
957      };
958    }
959    if (sub === 'popup' || sub === 'pop-up' || sub === 'window' || sub === 'card') {
960      const cfg = await setControllerConfiguration($, 'popup', rest[0] || 'top-right');
961      return {
962        text: 'Opened Spotify Popup Window (' + cfg.screenPosition + ').'
963      };
964    }
965    if (sub === 'mode') {
966      if (!rest[0]) {
967        return { text: describeConfig() };
968      }
969      const cfg = await setControllerConfiguration($, rest[0], rest[1] || null);
970      return {
971        text:
972          'Configured Spotify Mod mode: ' +
973          cfg.controllerMode +
974          ' at ' +
975          cfg.screenPosition +
976          ' [' +
977          cfg.sizePreset +
978          '].'
979      };
980    }
981    if (sub === 'voice' || sub === 'listen' || sub === 'mic') {
982      isUiHidden = false;
983      hasActiveTrack = true;
984      if (controllerMode === 'side-panel') {
985        await openPlayerPane($, { focus: false });
986      }
987      const voiceRes = await runVoiceSearch($, restText);
988      if (!voiceRes) {
989        return { text: 'Voice search ready. Click Voice in the status bar or pass /spotify voice <query>.' };
990      }
991      return {
992        text:
993          'Voice Search ("' +
994          voiceRes.transcript +
995          '") -> Playing "' +
996          voiceRes.track.title +
997          '" by ' +
998          (voiceRes.track.artist || 'Spotify')
999      };
1000    }
1001    if (sub === 'pause' || sub === 'resume') {
1002      const wantPlaying = sub === 'resume' || !isPlaying;
1003      if (wantPlaying) {
1004        const meta = await playSpotifyTarget($, currentTrack.id, screenPosition, controllerMode);
1005        return { text: 'Resumed: ' + meta.title + ' - ' + meta.artist };
1006      }
1007      isPlaying = false;
1008      await runNativePlayerCommand($, ['pause']);
1009      $.ui.invalidate('ui.render');
1010      return { text: 'Paused: ' + currentTrack.title + ' - ' + currentTrack.artist };
1011    }
1012    if (sub === 'next' || sub === 'skip') {
1013      const nextMeta = await stepPlaylist($, 1);
1014      return { text: 'Next track: ' + nextMeta.title + ' - ' + nextMeta.artist };
1015    }
1016    if (sub === 'prev' || sub === 'previous' || sub === 'back') {
1017      const prevMeta = await stepPlaylist($, -1);
1018      return { text: 'Previous track: ' + prevMeta.title + ' - ' + prevMeta.artist };
1019    }
1020    if (sub === 'stop') {
1021      await stopPlayback($);
1022      return { text: 'Stopped Spotify Mod.' };
1023    }
1024    if (sub === 'hide') {
1025      isUiHidden = true;
1026      await $.ui.close({ id: PANE_ID });
1027      $.ui.invalidate('ui.render');
1028      return {
1029        text: 'Spotify Mod UI hidden' + (isPlaying ? ' (still playing; /spotify show to bring it back).' : '.')
1030      };
1031    }
1032    if (sub === 'pos' || sub === 'position') {
1033      if (!restText) {
1034        return { text: describeConfig() };
1035      }
1036      const [posToken, sizeToken] = rest;
1037      const updated = await movePlayerPosition($, posToken, sizeToken);
1038      if (updated.kind === 'ui') {
1039        return { text: 'Spotify Mod UI placement set to: ' + updated.placement };
1040      }
1041      return {
1042        text:
1043          'Moved Spotify Mod to ' +
1044          updated.position +
1045          (screenPosition === 'custom' && customCoords ? ' (' + customCoords + ')' : '') +
1046          ' [' +
1047          updated.sizePreset +
1048          '] (' +
1049          updated.controllerMode +
1050          ')'
1051      };
1052    }
1053    if (sub === 'width') {
1054      const cols = Number(rest[0]);
1055      if (!Number.isInteger(cols) || cols < 28 || cols > 160) {
1056        return { text: 'Usage: /spotify width <28-160> (now ' + paneColumns + ')' };
1057      }
1058      paneColumns = cols;
1059      await persistSettings($);
1060      if (!isUiHidden && controllerMode === 'side-panel') {
1061        await $.ui.close({ id: PANE_ID });
1062        await openPlayerPane($, { focus: false });
1063      }
1064      $.ui.invalidate('ui.render');
1065      return { text: 'Sidebar width set to ' + cols + ' columns.' };
1066    }
1067    if (sub === 'status') {
1068      return {
1069        text:
1070          (isPlaying ? 'Playing: ' : 'Paused: ') +
1071          currentTrack.title +
1072          ' - ' +
1073          (currentTrack.artist || 'Spotify') +
1074          '\n' +
1075          describeConfig()
1076      };
1077    }
1078
1079    const parts = sub === 'play' || sub === 'search' ? rest.slice() : rawArgs.split(/\s+/);
1080    let modeOrSizeOverride = null;
1081    let posOverride = null;
1082
1083    const knownModesAndSizes = new Set([
1084      'mini',
1085      'tiny',
1086      'compact',
1087      'large',
1088      'sidebar',
1089      'panel',
1090      'side-panel',
1091      'popup',
1092      'pop-up',
1093      'window',
1094      'card'
1095    ]);
1096    const knownPositions = new Set([
1097      'top-left',
1098      'top-center',
1099      'top-right',
1100      'left-side',
1101      'center',
1102      'right-side',
1103      'bottom-left',
1104      'bottom-center',
1105      'bottom-right',
1106      'tl',
1107      'tc',
1108      'tr',
1109      'bl',
1110      'bc',
1111      'br',
1112      'left',
1113      'right',
1114      'top',
1115      'bottom',
1116      'dock-left',
1117      'dock-right',
1118      'pull-left',
1119      'pull-right'
1120    ]);
1121
1122    if (parts.length >= 2 && knownModesAndSizes.has(parts[parts.length - 1].toLowerCase())) {
1123      modeOrSizeOverride = parts.pop().toLowerCase();
1124    }
1125    if (parts.length >= 2) {
1126      const last = parts[parts.length - 1];
1127      if (last.startsWith('@')) {
1128        posOverride = parts.pop().slice(1);
1129      } else if (knownPositions.has(last.toLowerCase()) || /^\d+,\d+/.test(last)) {
1130        posOverride = parts.pop();
1131      }
1132    }
1133
1134    isUiHidden = false;
1135    const meta = await playSpotifyTarget($, parts.join(' '), posOverride, modeOrSizeOverride);
1136    if (controllerMode === 'side-panel') {
1137      await openPlayerPane($, { focus: false });
1138    } else {
1139      await $.ui.close({ id: PANE_ID });
1140    }
1141    $.ui.toast(
1142      'Playing "' +
1143        meta.title +
1144        '" - ' +
1145        (meta.artist || 'Spotify')
1146    );
1147    return {};
1148  });
1149
1150  on('tool.call', { tool: 'mcp__spotify-mod__spotify_player' }, async ($, e) => {
1151    try {
1152      const action = String(e.action || 'status').toLowerCase();
1153      if (action === 'play' || action === 'search') {
1154        const meta = await playSpotifyTarget($, e.queryOrUrl || '', e.position, e.mode || e.size);
1155        if (controllerMode === 'side-panel') {
1156          await openPlayerPane($, { focus: false });
1157        } else {
1158          await $.ui.close({ id: PANE_ID });
1159        }
1160        return {
1161          result:
1162            'Playing "' +
1163            meta.title +
1164            '" by ' +
1165            (meta.artist || 'Spotify') +
1166            ' (' +
1167            meta.id +
1168            ') in ' +
1169            controllerMode +
1170            ' mode at ' +
1171            screenPosition +
1172            ' [' +
1173            sizePreset +
1174            '].'
1175        };
1176      }
1177      if (action === 'login' || action === 'auth') {
1178        await openSpotifyLogin($);
1179        return {
1180          result: 'Opened Spotify Login window so the user can authenticate for full-length tracks.'
1181        };
1182      }
1183      if (action === 'logout') {
1184        await logoutSpotify($);
1185        return {
1186          result: 'Logged out of Spotify account.'
1187        };
1188      }
1189      if (action === 'voice' || action === 'listen') {
1190        const voiceRes = await runVoiceSearch($, e.queryOrUrl || '');
1191        if (!voiceRes) {
1192          return { result: 'Voice search triggered; waiting for microphone input.' };
1193        }
1194        return {
1195          result:
1196            'Voice search ("' +
1197            voiceRes.transcript +
1198            '") playing "' +
1199            voiceRes.track.title +
1200            '" by ' +