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

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.

Watch the zoomed feature tour with audio: demo/spotify-mod-demo.mp4
/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.
/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
Only one surface is active at a time (Sidebar and Popup Window never open together):
mini, default) — Inline AbovePrompt bar (SPOTIFY [MINI] Track - Artist [▶] [⏸] [⏭] [Voice] [Sidebar] [Popup] [Login] + inline Search input). Background audio runs headlessly with zero popout windows.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.popup) — Opens only the compact floating macOS window and closes the Claude Code sidebar.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.
Trigger voice search in three ways:
Voice in the Inline Mini Status Bar or Sidebar (uses native on-device SFSpeechRecognizer + AVAudioEngine microphone transcription)Voice in the Popup Window header/spotify voice (or /spotify voice play midnight city)claude plugin validate --strict .
claude plugin test .
swiftc -O native/SpotifyMiniPlayer.swift -o bin/spotify-pip -framework Cocoa -framework WebKit -framework Speech -framework AVFoundation
MIT (c) Hemanth.HM
hooks/register.js 1594 lines1/**
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 ' +