SLOPSHOPPER

spotify

Icon-only Spotify controls above the prompt (prev/play-pause/next/mute, AppleScript, no login) with a fullscreen sidebar for browsing/playing your playlists…

newpanebandcommandprocessnetwork
v0.15.0MITupdated 2026-09-30ambareeshav/spotify
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spotify
› 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 ⎿ spotify: $Spotify · controls above the prompt, ⛶ for playlists · Esc returns to it · /spotify stop closes ▣ client module ./boards/ticker.tsx ⟨Claude Code's own drawing⟩ ✕ [ ◫ ] [ ⌄ ] [ open Spotify ] [ ↻ ] Spotify isn't running ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./boards/ticker.tsx ⟨Claude Code's own drawing⟩ ✕ [ ◫ ] [ ⌄ ] [ open Spotify ] [ ↻ ] Spotify isn't running
README

Claude Code Spotify Mod

<img width="128" height="128" alt="467aaf4c-bac3-48b5-806b-af480625bb20" src="https://github.com/user-attachments/assets/cd191646-521c-4554-b445-f4e06c5fefdc" /> A Spotify plugin for Claude Code. Skip a bad song without leaving the terminal. /spotify puts play/pause/skip/mute, the album cover and what's playing right above the prompt, with nothing to set up beyond having Spotify open. ◫ opens a sidebar with the album cover, and once you connect your own (free) Spotify app, search and your playlists too.

Works out of the box: playback controls, now playing, album art. Needs your own Spotify Client ID (a couple of minutes, see below): search, playlists, Liked Songs.

Install

Part of Ambareesha's Claude Code plugins. Add the marketplace once, then install spotify from it.

Inside Claude Code:

/plugin marketplace add ambareeshav/claude-plugins
/plugin install spotify@ambareeshav

Or, once the marketplace is added, run /plugin, open the ambareeshav marketplace, and install spotify from the list.

If you're continuing a session, run /reload-plugins to turn it on, and /spotify is available.

From the terminal:

claude plugin marketplace add ambareeshav/claude-plugins
claude plugin install spotify@ambareeshav

Play

  1. /spotify opens the band (/spotify stop closes it).
  2. Buttons, icons only: ✕ close, ◫ sidebar, ⌄/⌃ collapse/expand, ⏮ prev, ▶/⏸ play/pause, ⏭ next, 🔇/🔈 mute/unmute. Track/artist/album and a position bar are shown below them, ticking on their own once a second.
  3. ⌄ collapses the band to one line — buttons, then │ track — artist │ 1:23/4:56 — for when three rows is more than you want to spare; ⌃ expands it back.
  4. If Spotify isn't open, the band offers an open Spotify button instead of controls.
  5. ◫ opens a sidebar (Pane) — a now-playing header with the album cover (in terminals with kitty graphics: Ghostty, kitty; elsewhere just the text) over a black → rust gradient, then a Playlists list (with Liked Songs always listed first, then playlists you created yourself — followed and other-owned playlists don't show up here, see below), and inside one, its tracks to play individually, ▶ play the whole thing in order, or 🔀 shuffle-play it. ‹ goes back to the list. A search box at the top searches Spotify's whole catalog (songs, albums, artists, playlists — up to 10 of each); a song plays on press; an album, an artist (→ its albums → their tracks) or a playlist you own opens (marked ›) with ▶/🔀 and per-track play that continues through the rest; a playlist someone else owns just plays, since Spotify won't let apps read its tracks. Results are grouped under Songs / Albums / Artists / Playlists; icons: ▸ song, ◉ album, ☺ artist, ≡ playlist. Every list loads in full (page by page, 50 at a time) and scrolls under a pinned header.

Connecting the sidebar

The band needs nothing: it talks to the Spotify desktop app directly. Search and playlists go through the Spotify Web API, which needs a Spotify app of your own. Spotify only lets an app in Development Mode serve its owner and up to 5 added users, so this mod can't ship a shared one.

  1. Go to developer.spotify.com/dashboard and create an app (Spotify requires a Premium account for this). Set the Redirect URI to exactly http://127.0.0.1:8907/callback and tick Web API.
  2. Copy the app's Client ID and run /spotify config <client-id>. It's saved for every session. (/spotify config on its own shows the current one.)
  3. Open the sidebar with ◫ and press connect Spotify. Your browser opens Spotify's login page; approve it, and the sidebar connects on its own within a second or two.

As the app's owner you're let in automatically, so there's no one to add under User Management. There's no client secret anywhere: the login uses PKCE, where the Client ID isn't a secret.

If the sidebar doesn't connect by itself (no python3, or something else is using port 8907), the browser shows a "can't reach this page" error with the code in the address bar. Paste that URL into the sidebar's input and press Enter.

You can also set the Client ID as the plugin's clientId setting (your Claude Code plugin settings, or .claude/settings.json); that wins over /spotify config.

Starting over: /spotify logout clears the stored login without touching your Spotify account's own authorization. Press connect Spotify afterward to log in again.

How it works

  • The band talks to the local Spotify desktop app via osascript (AppleScript) — no login, no API keys.
  • Reading "what's playing" checks whether Spotify is running (via System Events) first and never launches it just to check. The transport buttons do launch it if it's closed, the same as clicking its dock icon would.
  • It's narrow (icon-only buttons need far less room than labeled ones) and draws in a row with {await next(e)} — the same AbovePrompt site other mods (tetris, pong) use, so it can sit beside whatever else draws there instead of hiding it.
  • A zero-size Client (boards/ticker.tsx) is mounted purely for its drawing-thread timer: it posts a tick once a second, which the hooks module answers by re-running the AppleScript query and invalidating the render. That's what keeps the position/progress bar moving without a button press — the hooks module has no timer of its own, only a Client's surface.every does.
  • Mute remembers the volume it muted from (in memory, for this session) so unmute restores it instead of guessing.
  • Collapsed (⌄/⌃) mode drops the fixed narrow column width and lets the row size to its content instead — the whole point of asking for one wide line rather than three narrow ones.
  • The sidebar has no transport controls, since the band already owns those — just a now-playing header with the cover (fetched once per track via AppleScript's artwork url, converted to PNG with sips, drawn with Image). The pane scrolls its own list (a ui.scroll hook) rather than letting the engine scroll the whole tree, so the cover never moves — a moving kitty image made Ghostty's scrolling stutter. It talks to the real Spotify Web API, authenticated via OAuth's Authorization Code + PKCE flow — no client secret, since PKCE's whole point is not needing one for a public/desktop client.
  • Login spawns a short-lived local Python HTTP server (python3, backgrounded via nohup ... & disown so the spawning call returns immediately) on 127.0.0.1:8907 to catch the OAuth redirect — $.process.run itself only runs one-shot commands that it waits on, not long-lived listeners, so the listener has to be launched detached from it. It loops on the real callback rather than answering exactly once — a single-shot server that exits after the very first connection is gone by the time the real redirect lands if anything else reaches the port first (a browser's speculative preconnect, a stray favicon fetch), which reads as the page hanging ("took too long to respond"), not the expected instant "can't reach this page." A pkill before spawning a new one also clears out any listener still bound to the port from an earlier, abandoned attempt (it can sit alive for up to three minutes) — otherwise a retry's new listener silently fails to bind at all. It writes what it catches to a state-scoped file, and the same Client ticker that drives the band's progress bar also polls for that file once a second while a login is pending, completing it automatically. If the listener can't start (no python3) or the redirect never reaches it, the sidebar's paste-the-URL Input is still there as a fallback — the loopback redirect URI needs no real listener either way, since the code lands in the browser's address bar regardless of whether anything answers it. The page the browser lands on is styled (dark, a check or an ✕, auto-closes the tab after a second and a half) and distinguishes a real success from Spotify denying the request, rather than showing the same "connected" text regardless.
  • /spotify logout clears the stored tokens and all sidebar state.
  • Tokens (access + refresh) persist in $.store across sessions; the access token silently refreshes on expiry (or on a 401), once per call, before failing for real.
  • A failed request (a 403, a flaky connection, whatever) shows as a small dismissible banner, not a screen that replaces the whole sidebar — navigating back and retrying always stays available. An earlier version let one failed request hide the entire playlists list and its back button; this is the fix.
  • Playlist tracks come from /playlists/{id}/items, not /playlists/{id}/tracks — Spotify deprecated the latter in an early-2026 API migration. The response's per-item field also renamed (track → item); this mod reads item and falls back to track only for safety.
  • The list only shows playlists you created. As of a Feb 2026 Spotify API change, /playlists/{id}/items only serves tracks to the account that owns the playlist — a followed, collaborative-but-not-yours, or algorithmic playlist (Discover Weekly, Daily Mix, a Blend, ...) always 403s there now, for any third-party app, permanently. Rather than list something that's guaranteed to fail when opened, loadPlaylists fetches the account's own id from /me once and filters /me/playlists down to owner.id === that id (lib/spotifyApi.ts's toPlaylists) before the sidebar ever sees it.
  • Search uses /v1/search (no extra scope). Since Spotify's Feb 2026 change its limit caps at 10 per type, so the sidebar shows the top 10 of each rather than paging. Playing a searched playlist you don't own works (it's a context_uri, not an /items read), even though opening one wouldn't. Artists open to /artists/{id}/albums (singles + albums) — Spotify removed /artists/{id}/top-tracks in the same change.
  • Liked Songs isn't a real playlist — Spotify's /me/playlists never lists it, since it isn't a playlist resource. This mod synthesizes an entry for it from /me/tracks (the separate "Your Music" saved-tracks endpoint) instead, so it shows up and works the same as any other playlist to browse or play. It has no context_uri of its own either, so playing it (in order or shuffled) sends the loaded tracks' own URIs directly rather than asking Spotify to play a playlist context — capped at 500 to keep the request small. It isn't subject to the owner-only filter above, since it's read from a different endpoint entirely.

Requirements

  • macOS with the Spotify desktop app installed (not the web player) — the band always needs this.
  • Claude Code with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 — the $ API is early access and may change between releases.
  • The first control you press may prompt macOS for permission to let Claude Code (your terminal) control Spotify via Accessibility/Automation — allow it once.
  • For search and playlists: your own Spotify app's Client ID and a one-time browser login (see above). Creating the app, and starting playback remotely via the Web API, both need Spotify Premium (Spotify's rules, not this mod's).
  • Only playlists you created yourself show up in the sidebar. Followed, collaborative-but-not-yours, and algorithmic playlists (Discover Weekly, Daily Mix, a Blend, ...) are filtered out before they're ever listed — their tracks are permanently unreadable via the Web API for any third-party app as of a Feb 2026 Spotify change, so there's nothing to show for them. Liked Songs is unaffected (different endpoint).

Known limitations / next steps

  • Search and playlists need your own Spotify app. Spotify's Development Mode caps an app at its owner plus 5 users, so there's no shared app to fall back on.
  • The local OAuth listener needs python3 and a free port 8907 — if either's missing, connecting falls back to copy-pasting the redirect URL by hand.
  • The progress bar ticks once a second, driven by the ticker Client's own timer — not sample-accurate, but close enough to read at a glance.
  • Session-scoped mute memory. The volume mute restores to resets on plugin reload.
  • Playing needs an active Spotify Connect device. If nothing's playing anywhere, the mod looks up your devices and targets one, but if none exist yet it asks you to press play in Spotify once first — a Web API restriction, not a workaround avoided.
Source 5 files
hooks/register.tsx 1146 lines
1/* @jsx h */
2import type { Register } from 'claude-code';
3import { formatTime, parseNowPlaying, progressBar, type NowPlaying } from './lib/applescript';
4import {
5  REDIRECT_URI,
6  authUrl,
7  callbackFilePath,
8  challengeFor,
9  isClientId,
10  extractCode,
11  loopbackServerScript,
12  randomState,
13  randomVerifier,
14  serverScriptFilePath,
15} from './lib/spotifyAuth';
16import { shuffled, tokenSetFrom, toPlaylists, toPlaylistTracks, toArtistAlbums, toSearchResults, type Playlist, type PlaylistTrack, type SearchResult, type TokenSet } from './lib/spotifyApi';
17
18// Spotify controls above the prompt (the same band tetris and pong draw in), plus a fullscreen
19// sidebar for the things AppleScript can't do at all: browsing/playing playlists. The band talks
20// to the local Spotify desktop app via `osascript` — no login, macOS only.
21// The sidebar talks to the real Spotify Web API — needs a one-time OAuth login (PKCE, no client
22// secret), auto-completed by a short-lived local listener rather than a copy-paste.
23
24const PANE_ID = 'spotify-full';
25const TOKEN_STORE_KEY = 'spotify:tokens';
26const CLIENT_ID_STORE_KEY = 'spotify:clientId';
27// not a real playlist id — Spotify never returns "Liked Songs" from /me/playlists at all (it's
28// not a playlist resource), so this mod synthesizes one from /me/tracks (the "Your Music" saved
29// tracks endpoint) to make it browsable/playable the same way as any other playlist
30const LIKED_SONGS_ID = '__liked__';
31
32// wide enough for the fullest row — close, full, prev, play/pause, next, mute, each bracketed
33// (`[ X ]`) and gapped — without clipping the trailing ones out of the clickable area
34const WIDTH = 52;
35const BAND_ART_COLUMNS = 6;
36
37// the fields the AppleScript prints, joined by FIELD_SEP, in this order.
38// `playerState`, not `st` — Spotify's own scripting dictionary reserves `st` and refuses to
39// parse it as a plain identifier ("Expected expression but found 'st'").
40const NOW_PLAYING_SCRIPT = `
41set isRunning to false
42tell application "System Events"
43  set isRunning to (name of processes) contains "Spotify"
44end tell
45if isRunning then
46  tell application "Spotify"
47    set trackId to ""
48    set trackName to ""
49    set trackArtist to ""
50    set trackAlbum to ""
51    set trackDuration to 0
52    try
53      set trackId to id of current track
54      set trackName to name of current track
55      set trackArtist to artist of current track
56      set trackAlbum to album of current track
57      set trackDuration to duration of current track
58    end try
59    set trackPosition to player position
60    set playerState to player state as string
61    set playerVolume to sound volume
62    set sep to ASCII character 31
63    return trackId & sep & trackName & sep & trackArtist & sep & trackAlbum & sep & (trackDuration as string) & sep & (trackPosition as string) & sep & playerState & sep & (playerVolume as string)
64  end tell
65else
66  return "not running"
67end if
68`;
69
70let open = false;
71let compact = false; // one line instead of three, while something's playing — see renderBand
72let nowPlaying: NowPlaying = { running: false };
73let lastVolume = 70;
74let errorMessage: string | null = null;
75
76let auth: TokenSet | null = null;
77let pendingLogin: { verifier: string; state: string } | null = null;
78let loginError: string | null = null;
79let paneError: string | null = null;
80let playlists: Playlist[] | null = null;
81let selectedPlaylist: Playlist | null = null;
82let playlistTracks: PlaylistTrack[] | null = null;
83let searchQuery: string | null = null; // non-null while the sidebar shows search results
84let searchResults: SearchResult[] | null = null;
85// what was opened from search, innermost last: search → artist → album, or search → album/playlist.
86// ‹ pops one; an empty stack is the results list itself
87type BrowseFrame = { item: SearchResult; tracks: PlaylistTrack[] | null; albums: SearchResult[] | null };
88let browseStack: BrowseFrame[] = [];
89let myUserId: string | null = null; // cached from /me, so loadPlaylists doesn't refetch it every time
90
91// the person's own Spotify app: `/spotify config <id>` stores it (read fresh each time, so every
92// open session sees a change), and the plugin's `clientId` setting, if set, wins over that
93async function clientIdFrom($: any, options: any): Promise<string | null> {
94  const fromSettings = String(options?.clientId ?? '').trim();
95  if (fromSettings) return fromSettings;
96  try {
97    const stored = await $.store.get(CLIENT_ID_STORE_KEY);
98    return typeof stored === 'string' && stored ? stored : null;
99  } catch {
100    return null;
101  }
102}
103
104async function requireClientId($: any, options: any): Promise<string> {
105  const id = await clientIdFrom($, options);
106  if (!id) throw new Error('no Spotify Client ID set — run /spotify config <client-id> first (see the README)');
107  return id;
108}
109
110// checked once per sidebar draw, for the setup screen (a render hook can't await per button)
111let hasClientId = false;
112
113// ---------- local desktop control (AppleScript) ----------
114
115async function runOsa($: any, script: string): Promise<string> {
116  const res = await $.process.run(['osascript', '-e', script]);
117  if (res.exitCode !== 0) throw new Error(res.stderr?.trim() || 'osascript failed');
118  return res.stdout as string;
119}
120
121async function refreshNowPlaying($: any): Promise<void> {
122  try {
123    const raw = await runOsa($, NOW_PLAYING_SCRIPT);
124    nowPlaying = parseNowPlaying(raw);
125    errorMessage = null;
126  } catch (err: any) {
127    errorMessage = err?.message ?? String(err);
128  }
129}
130
131// ---------- album art (the sidebar's header) ----------
132
133// the art as a local PNG the terminal reads itself (kitty graphics — Ghostty, kitty); anywhere
134// else `Image` draws its alt text instead. Spotify serves JPEGs and `Image` only takes PNG, so
135// curl + macOS's own `sips` convert it once per track, keyed by track id so a redraw of the same
136// track sends nothing new
137let art: { trackId: string; path: string | null } | null = null;
138// this session's own prefix: every Claude Code session runs its own copy of this module, and a
139// shared `rm /tmp/spotify-mod-art-*` let one session delete the file another was still drawing
140const ART_PREFIX = `/tmp/spotify-mod-art-${Math.random().toString(36).slice(2, 10)}-`;
141let artLoading = false;
142
143async function refreshArt($: any): Promise<void> {
144  if (!nowPlaying.running || artLoading) return;
145  const trackId = nowPlaying.trackId;
146  if (art?.trackId === trackId) return;
147  artLoading = true;
148  try {
149    const url = (await runOsa($, 'tell application "Spotify" to artwork url of current track')).trim();
150    const path = `${ART_PREFIX}${trackId.replace(/[^A-Za-z0-9]/g, '')}.png`;
151    // this session's previous track's art first, so it holds one image at a time
152    const res = await $.process.run([
153      '/bin/sh',
154      '-c',
155      'rm -f "$3"*; curl -sfL "$1" -o "$2.jpg" && sips -s format png "$2.jpg" --out "$2" >/dev/null && rm -f "$2.jpg"',
156      'sh',
157      url,
158      path,
159      ART_PREFIX,
160    ]);
161    art = { trackId, path: url.startsWith('https://') && res.exitCode === 0 ? path : null };
162  } catch {
163    art = { trackId, path: null }; // no art is fine — the header just goes without
164  } finally {
165    artLoading = false;
166  }
167}
168
169async function openSpotify($: any): Promise<void> {
170  await $.process.run(['open', '-a', 'Spotify']);
171}
172
173async function playPause($: any): Promise<void> {
174  await runOsa($, 'tell application "Spotify" to playpause');
175}
176
177async function nextTrack($: any): Promise<void> {
178  await runOsa($, 'tell application "Spotify" to next track');
179}
180
181async function previousTrack($: any): Promise<void> {
182  await runOsa($, 'tell application "Spotify" to previous track');
183}
184
185async function toggleMute($: any): Promise<void> {
186  const raw = await runOsa($, 'tell application "Spotify" to sound volume');
187  const vol = Number(raw) || 0;
188  if (vol > 0) {
189    lastVolume = vol;
190    await runOsa($, 'tell application "Spotify" to set sound volume to 0');
191  } else {
192    await runOsa($, `tell application "Spotify" to set sound volume to ${lastVolume > 0 ? lastVolume : 70}`);
193  }
194}
195
196// ---------- Spotify Web API (auth + calls) ----------
197
198async function loadAuth($: any): Promise<void> {
199  try {
200    const raw = await $.store.get(TOKEN_STORE_KEY);
201    if (raw && typeof raw === 'object') auth = raw as TokenSet;
202  } catch {
203    // no stored session is not an error — just start logged out
204  }
205}
206
207async function saveAuth($: any): Promise<void> {
208  if (!auth) return;
209  await $.store.set(TOKEN_STORE_KEY, auth).catch((err: any) => $.ui.log(`spotify: token store write failed: ${err}`));
210}
211
212async function exchangeCode($: any, options: any, code: string): Promise<void> {
213  if (!pendingLogin) throw new Error('no login in progress — press "connect Spotify" again');
214  const body = new URLSearchParams({
215    grant_type: 'authorization_code',
216    code,
217    redirect_uri: REDIRECT_URI,
218    client_id: await requireClientId($, options),
219    code_verifier: pendingLogin.verifier,
220  });
221  const res = await $.http.fetch('https://accounts.spotify.com/api/token', {
222    method: 'POST',
223    headers: { 'content-type': 'application/x-www-form-urlencoded' },
224    body: body.toString(),
225  });
226  if (!res.ok) throw new Error(`Spotify login failed: ${res.status} ${res.text.slice(0, 200)}`);
227  auth = tokenSetFrom(JSON.parse(res.text), '');
228  pendingLogin = null;
229  await saveAuth($);
230}
231
232async function refreshAccessToken($: any, options: any): Promise<void> {
233  if (!auth) throw new Error('not connected');
234  const body = new URLSearchParams({
235    grant_type: 'refresh_token',
236    refresh_token: auth.refreshToken,
237    client_id: await requireClientId($, options),
238  });
239  const res = await $.http.fetch('https://accounts.spotify.com/api/token', {
240    method: 'POST',
241    headers: { 'content-type': 'application/x-www-form-urlencoded' },
242    body: body.toString(),
243  });
244  if (!res.ok) {
245    auth = null; // a dead refresh token loops forever otherwise — force a real re-login instead
246    await $.store.set(TOKEN_STORE_KEY, null).catch(() => {});
247    throw new Error(`Spotify session expired — connect again (${res.status})`);
248  }
249  auth = tokenSetFrom(JSON.parse(res.text), auth.refreshToken);
250  await saveAuth($);
251}
252
253async function ensureFreshToken($: any, options: any): Promise<string> {
254  if (!auth) throw new Error('not connected — press "connect Spotify" first');
255  if (Date.now() >= auth.expiresAt - 60_000) await refreshAccessToken($, options);
256  return auth!.accessToken;
257}
258
259async function spotifyApi($: any, options: any, method: string, path: string, body?: unknown, retried = false): Promise<any> {
260  const token = await ensureFreshToken($, options);
261  const res = await $.http.fetch(`https://api.spotify.com/v1${path}`, {
262    method,
263    headers: {
264      authorization: `Bearer ${token}`,
265      ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
266    },
267    ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
268  });
269  if (res.status === 401 && !retried) {
270    await refreshAccessToken($, options);
271    return spotifyApi($, options, method, path, body, true);
272  }
273  if (res.status === 204 || !res.text) return null;
274  // status leads, not trails — the band's dim error line truncates long text, and the status
275  // code is the one part of this that must survive that; a full URL with query params easily
276  // eats the whole line's width budget before ever reaching what "→ 403" used to end with
277  if (!res.ok) throw new Error(`Spotify ${res.status}: ${method} ${path} ${res.text.slice(0, 200)}`);
278  return JSON.parse(res.text);
279}
280
281// its own try/catch, not a generic one: a login failure belongs in `loginError`, which the
282// not-connected screen renders — routing it anywhere else used to render as nothing at all
283async function startLogin($: any, options: any): Promise<void> {
284  try {
285    const clientId = await requireClientId($, options);
286    const verifier = randomVerifier();
287    const state = randomState();
288    const challenge = await challengeFor(verifier);
289    pendingLogin = { verifier, state };
290    loginError = null;
291    // best-effort: writes and backgrounds the listener that lets the tick handler (below) pick
292    // the code up on its own. If python3 is missing, this silently does nothing and the
293    // manual-paste `Input` in the sidebar still works. `pkill` first clears out any listener
294    // still bound to the port from an earlier, abandoned attempt (it can sit alive for up to
295    // three minutes) — without it, a retry's new listener fails to bind at all ("address already
296    // in use"), silently, since this call is backgrounded and its exit code is never checked.
297    await $.fs.write(serverScriptFilePath(state), loopbackServerScript(state)).catch(() => {});
298    await $.process
299      .run(['/bin/sh', '-c', `pkill -f spotify-mod-server- 2>/dev/null; nohup python3 ${serverScriptFilePath(state)} > /dev/null 2>&1 & disown`])
300      .catch(() => {});
301    await $.process.run(['open', authUrl(clientId, challenge, state)]);
302  } catch (err: any) {
303    loginError = err?.message ?? String(err);
304  }
305}
306
307async function submitLoginUrl($: any, options: any, pasted: string): Promise<void> {
308  try {
309    if (!pendingLogin) throw new Error('press "connect Spotify" first');
310    const code = extractCode(pasted, pendingLogin.state);
311    await exchangeCode($, options, code);
312    loginError = null;
313    await loadPlaylists($, options).catch((err: any) => {
314      paneError = err?.message ?? String(err);
315    });
316  } catch (err: any) {
317    loginError = err?.message ?? String(err);
318  }
319}
320
321// polled once a second by the ticker's existing tick (only while a login is pending, so this
322// never runs otherwise): picks up what the backgrounded listener in startLogin caught, so the
323// sidebar's copy-paste `Input` is a fallback, not the only way in
324async function checkLoginCallback($: any, options: any): Promise<void> {
325  if (!pendingLogin) return;
326  const state = pendingLogin.state;
327  const path = callbackFilePath(state);
328  let exists = false;
329  try {
330    exists = await $.fs.exists(path);
331  } catch {
332    return;
333  }
334  if (!exists) return;
335  try {
336    const raw = await $.fs.read(path);
337    const data = JSON.parse(raw as string);
338    await $.process.run(['rm', '-f', path, serverScriptFilePath(state)]).catch(() => {});
339    if (data.state && data.state !== state) return; // a stale redirect from an earlier, abandoned login
340    if (data.error) throw new Error(`Spotify denied the request: ${data.error}`);
341    if (!data.code) throw new Error('the local callback caught a redirect with no code in it — try again');
342    await exchangeCode($, options, data.code);
343    loginError = null;
344    await loadPlaylists($, options).catch((err: any) => {
345      paneError = err?.message ?? String(err);
346    });
347  } catch (err: any) {
348    loginError = err?.message ?? String(err);
349  }
350}
351
352// clears everything a login built up, for a clean re-test of the whole flow (`/spotify logout`)
353async function logout($: any): Promise<void> {
354  auth = null;
355  pendingLogin = null;
356  loginError = null;
357  paneError = null;
358  playlists = null;
359  selectedPlaylist = null;
360  playlistTracks = null;
361  searchQuery = null;
362  searchResults = null;
363  browseStack = [];
364  myUserId = null;
365  await $.store.set(TOKEN_STORE_KEY, null).catch((err: any) => $.ui.log(`spotify: token store clear failed: ${err}`));
366}
367
368async function ensureMyUserId($: any, options: any): Promise<string> {
369  if (myUserId) return myUserId;
370  const json = await spotifyApi($, options, 'GET', '/me');
371  myUserId = json?.id;
372  if (!myUserId) throw new Error('Spotify: /me returned no account id');
373  return myUserId;
374}
375
376// Spotify pages every list (50 at most per request) and hands back `next`, the full URL of the
377// page after, null on the last. `onPage` returning false stops early — the view it was loading
378// for was left. Pages land one by one, each redrawn, so a long list shows its top straight away
379const API_BASE = 'https://api.spotify.com/v1';
380async function forEachPage($: any, options: any, firstPath: string, onPage: (json: any) => boolean | void): Promise<void> {
381  let path: string | null = firstPath;
382  while (path) {
383    const json = await spotifyApi($, options, 'GET', path);
384    if (onPage(json) === false) return;
385    $.ui.invalidate('ui.render');
386    const next = json?.next;
387    path = typeof next === 'string' && next.startsWith(API_BASE) ? next.slice(API_BASE.length) : null;
388  }
389}
390
391async function loadPlaylists($: any, options: any): Promise<void> {
392  // Liked Songs isn't in /me/playlists at all (Spotify doesn't treat it as a playlist resource),
393  // so it's synthesized here as its own entry, always listed first
394  const ownerId = await ensureMyUserId($, options);
395  const owned: Playlist[] = [];
396  await forEachPage($, options, '/me/playlists?limit=50', json => {
397    owned.push(...toPlaylists(json, ownerId));
398  });
399  playlists = [{ id: LIKED_SONGS_ID, name: 'Liked Songs' }, ...owned];
400}
401
402async function loadPlaylistTracks($: any, options: any, playlistId: string): Promise<void> {
403  const openedFor = selectedPlaylist;
404  // `/tracks` is Spotify's now-deprecated path for a playlist's — it started returning 403 for
405  // every request after Spotify's early-2026 API migration, even for a playlist you made yourself
406  // and are properly authorized for. `/items` is the replacement.
407  const firstPath = playlistId === LIKED_SONGS_ID ? '/me/tracks?limit=50' : `/playlists/${playlistId}/items?limit=50`;
408  try {
409    let first = true;
410    await forEachPage($, options, firstPath, json => {
411      if (selectedPlaylist !== openedFor) return false;
412      const page = toPlaylistTracks(json);
413      playlistTracks = first ? page : [...(playlistTracks ?? []), ...page];
414      first = false;
415    });
416  } catch (err: any) {
417    const message = err?.message ?? String(err);
418    if (!message.includes('403')) throw err;
419    // the sidebar only ever lists playlists this account owns (see loadPlaylists) — a playlist
420    // you don't own always 403s here (Spotify's Feb 2026 API change) and is filtered out before
421    // it can even be selected, so a 403 on something that did show up in the list means the
422    // account isn't registered on the Spotify app in use, not an ownership problem
423    const needs = playlistId === LIKED_SONGS_ID ? 'Liked Songs needs user-library-read' : 'playlists need playlist-read-private';
424    throw new Error(`${message}\n\nYour account isn't registered on this Spotify app (it's in Development Mode). Set your own app's clientId in the plugin config — see the README (${needs}).`);
425  }
426}
427
428async function getActiveDeviceId($: any, options: any): Promise<string | null> {
429  const json = await spotifyApi($, options, 'GET', '/me/player/devices');
430  const devices = Array.isArray(json?.devices) ? json.devices : [];
431  const active = devices.find((d: any) => d.is_active) ?? devices[0];
432  return active?.id ?? null;
433}
434
435async function playOnDevice($: any, options: any, body: unknown): Promise<void> {
436  try {
437    await spotifyApi($, options, 'PUT', '/me/player/play', body);
438    return;
439  } catch (err: any) {
440    if (!String(err?.message ?? err).includes('404')) throw err;
441  }
442  const deviceId = await getActiveDeviceId($, options);
443  if (!deviceId) throw new Error('no active Spotify device — open Spotify and play anything once, then retry');
444  await spotifyApi($, options, 'PUT', `/me/player/play?device_id=${deviceId}`, body);
445}
446
447async function playTrack($: any, options: any, uri: string): Promise<void> {
448  await playOnDevice($, options, { uris: [uri] });
449}
450
451async function playPlaylist($: any, options: any, playlistId: string, shuffle: boolean): Promise<void> {
452  if (playlistId === LIKED_SONGS_ID) {
453    // Liked Songs has no `context_uri` of its own (it isn't a playlist resource), so this plays
454    // the page of tracks already loaded for the sidebar directly, in order or shuffled here
455    if (!playlistTracks || playlistTracks.length === 0) {
456      throw new Error('open Liked Songs first so its tracks are loaded, then play');
457    }
458    const tracks = shuffle ? shuffled(playlistTracks) : playlistTracks;
459    // capped: every loaded track now (not one page), and a request body of thousands of URIs is
460    // asking for a 413 — 500 is still a long, properly mixed session
461    await playOnDevice($, options, { uris: tracks.slice(0, 500).map(t => t.uri) });
462    return;
463  }
464  if (shuffle) await spotifyApi($, options, 'PUT', '/me/player/shuffle?state=true').catch(() => {});
465  else await spotifyApi($, options, 'PUT', '/me/player/shuffle?state=false').catch(() => {});
466  await playOnDevice($, options, { context_uri: `spotify:playlist:${playlistId}` });
467}
468
469// no extra scope — /v1/search only needs a token. Spotify caps `limit` at 10 per type (it was 50
470// before the Feb 2026 API change), so this asks for the max of each rather than paging
471async function runSearch($: any, options: any, query: string): Promise<void> {
472  searchQuery = query;
473  searchResults = null;
474  browseStack = [];
475  $.ui.invalidate('ui.render');
476  const params = new URLSearchParams({ q: query, type: 'track,album,artist,playlist', limit: '10' });
477  await ensureMyUserId($, options).catch(() => {}); // for canOpen's ownership check
478  const json = await spotifyApi($, options, 'GET', `/search?${params.toString()}`);
479  searchResults = toSearchResults(json);
480}
481
482async function playSearchResult($: any, options: any, r: SearchResult): Promise<void> {
483  await playOnDevice($, options, r.kind === 'track' ? { uris: [r.uri] } : { context_uri: r.uri });
484}
485
486// a playlist you don't own can still be *played* (it's just a context_uri), but its tracks 403
487// since Spotify's Feb 2026 change — so only an owned one opens; anything else plays on press
488function canOpen(r: SearchResult): boolean {
489  if (r.kind === 'album' || r.kind === 'artist') return true;
490  return r.kind === 'playlist' && !!myUserId && r.ownerId === myUserId;
491}
492
493async function openSearchResult($: any, options: any, item: SearchResult): Promise<void> {
494  const frame: BrowseFrame = { item, tracks: null, albums: null };
495  browseStack = [...browseStack, frame];
496  $.ui.invalidate('ui.render');
497  try {
498    await loadFrame($, options, frame);
499  } catch (err) {
500    // an empty list plus the error banner, rather than "loading…" forever
501    if (item.kind === 'artist') frame.albums = [];
502    else frame.tracks = [];
503    throw err;
504  }
505}
506
507async function loadFrame($: any, options: any, frame: BrowseFrame): Promise<void> {
508  const item = frame.item;
509  if (item.kind === 'artist') {
510    const json = await spotifyApi($, options, 'GET', `/artists/${item.id}/albums?include_groups=album,single&limit=10`);
511    frame.albums = toArtistAlbums(json);
512  } else {
513    const firstPath = item.kind === 'album' ? `/albums/${item.id}/tracks?limit=50` : `/playlists/${item.id}/items?limit=50`;
514    await forEachPage($, options, firstPath, json => {
515      if (!browseStack.includes(frame)) return false;
516      frame.tracks = [...(frame.tracks ?? []), ...toPlaylistTracks(json)];
517    });
518  }
519}
520
521async function playContext($: any, options: any, contextUri: string, shuffle: boolean): Promise<void> {
522  await spotifyApi($, options, 'PUT', `/me/player/shuffle?state=${shuffle}`).catch(() => {});
523  await playOnDevice($, options, { context_uri: contextUri });
524}
525
526// the list view's own fallback load. openFullscreen loads playlists when the band's ◫ opens the
527// pane, but a pane that outlives a module reload (/reload-plugins, an update) comes back with
528// `playlists` reset to null and nothing left to load it, so it said "loading…" forever
529let playlistsLoading = false;
530function ensurePlaylistsLoading($: any, options: any): void {
531  if (!auth || playlists !== null || playlistsLoading) return;
532  playlistsLoading = true;
533  loadPlaylists($, options)
534    .catch((err: any) => {
535      playlists = [];
536      paneError = err?.message ?? String(err);
537    })
538    .finally(() => {
539      playlistsLoading = false;
540      $.ui.invalidate('ui.render');
541    });
542}
543
544async function openFullscreen($: any, options: any): Promise<void> {
545  if (!auth) await loadAuth($);
546  if (auth && playlists === null) {
547    await loadPlaylists($, options).catch((err: any) => {
548      paneError = err?.message ?? String(err);
549    });
550  }
551  await $.ui.open({ id: PANE_ID, title: 'Spotify', focus: true, closeOnEscape: true }).catch((err: any) =>
552    $.ui.log(`spotify: ui.open failed: ${err}`),
553  );
554  $.ui.invalidate('ui.render');
555}
556
557// ---------- band (AbovePrompt) ----------
558
559function renderBand($: any, e: any, options: any) {
560  const { Box, Text, Button, Markdown, Client, Image } = $.ui.resolve(e);
561
562  const afterAction = (action: () => Promise<void>) => async () => {
563    try {
564      await action();
565    } catch (err: any) {
566      errorMessage = err?.message ?? String(err);
567    }
568    await refreshNowPlaying($);
569    $.ui.invalidate('ui.render');
570  };
571
572  const close = () => {
573    open = false;
574    $.ui.invalidate('ui.render');
575  };
576
577  const toggleCompact = () => {
578    compact = !compact;
579    $.ui.invalidate('ui.render');
580  };
581
582  // close is `plain` — just a padded glyph, no bracket chrome — since it's the one button that
583  // never needs the extra visual weight of a `[ X ]`; every other icon button below keeps the
584  // default bracket chrome, which gives them a uniform drawn width and real spacing around each
585  // (a wider row gap, 2 not 1, adds breathing room between the brackets too)
586  const closeButton = <Button key="spotify:close" plain label=" ✕ " onPress={close} />;
587  // ◫ reads as a sidebar layout (a pane split off from the main area), closer to what this
588  // button actually opens than ⛶'s generic "fullscreen" implication
589  const fullButton = (
590    <Button key="spotify:full" label="◫" onPress={() => openFullscreen($, options).catch((err: any) => $.ui.log(`spotify: ${err}`))} />
591  );
592  // a chevron pair (not two unrelated glyphs) so the button visibly toggles between the same two
593  // states — ⌄ "collapse this" while expanded, ⌃ "expand this" while compact
594  const compactButton = <Button key="spotify:compact" label={compact ? '⌃' : '⌄'} onPress={toggleCompact} />;
595  // a zero-size clock: its own timer posts a tick every second so the position/bar keep
596  // moving without needing a button press, even though the hooks module has no timer of its own
597  const ticker = <Client key="spotify:ticker" module="./boards/ticker.tsx" width={0} height={0} />;
598
599  let content;
600  let bandArt = false;
601  if (errorMessage) {
602    content = (
603      <Box flexDirection="column">
604        <Box flexDirection="row" columnGap={2}>
605          {closeButton}
606          {fullButton}
607          {compactButton}
608          <Button key="retry" label="↻" onPress={afterAction(async () => {})} />
609        </Box>
610        <Markdown text={`**Spotify mod error**\n\n${errorMessage}`} />
611      </Box>
612    );
613  } else if (!nowPlaying.running) {
614    content = (
615      <Box flexDirection="column">
616        <Box flexDirection="row" columnGap={2}>
617          {closeButton}
618          {fullButton}
619          {compactButton}
620          <Button key="open" label="open Spotify" onPress={afterAction(() => openSpotify($))} />
621          <Button key="refresh" label="↻" onPress={afterAction(async () => {})} />
622        </Box>
623        <Text dimColor>Spotify isn't running</Text>
624      </Box>
625    );
626  } else {
627    const np = nowPlaying;
628    // 🔈/🔇 — a plain speaker, not 🔊's three sound waves — sits closer in visual weight to the
629    // thin ⏮/⏸/⏭ transport glyphs than the louder, busier icon did
630    const muteButton = (
631      <Button key="mute" label={np.volume > 0 ? '🔇' : '🔈'} onPress={afterAction(() => toggleMute($))} />
632    );
633    const trackLine = `${np.track || '(unknown track)'} — ${np.artist}`;
634    // Image is terminal-only, and the compact one-liner has no room for it
635    bandArt = !compact && e.surface === 'terminal' && art?.trackId === np.trackId && !!art.path;
636    const timeLine = `${formatTime(np.positionSec)}/${formatTime(np.durationMs / 1000)}`;
637    content = compact ? (
638      <Box flexDirection="row" columnGap={2}>
639        {closeButton}
640        {fullButton}
641        {compactButton}
642        <Button key="prev" label="⏮" onPress={afterAction(() => previousTrack($))} />
643        <Button key="playpause" label={np.state === 'playing' ? '⏸' : '▶'} onPress={afterAction(() => playPause($))} />
644        <Button key="next" label="⏭" onPress={afterAction(() => nextTrack($))} />
645        {muteButton}
646        <Text dimColor>│</Text>
647        <Text wrap="truncate-end">{trackLine}</Text>
648        <Text dimColor>│</Text>
649        <Text dimColor>{timeLine}</Text>
650      </Box>
651    ) : (
652      <Box flexDirection="row" columnGap={2}>
653        {/* the stacked band is 3 rows; 6×3 cells reads as square (a cell is ~twice as tall as wide) */}
654        {bandArt && <Image key="band:art" source={{ file: art!.path, format: 'png' }} columns={BAND_ART_COLUMNS} rows={3} alt={np.album || ' '} />}
655        <Box flexDirection="column">
656          <Box flexDirection="row" columnGap={2}>
657            {closeButton}
658            {fullButton}
659            {compactButton}
660            <Button key="prev" label="⏮" onPress={afterAction(() => previousTrack($))} />
661            <Button key="playpause" label={np.state === 'playing' ? '⏸' : '▶'} onPress={afterAction(() => playPause($))} />
662            <Button key="next" label="⏭" onPress={afterAction(() => nextTrack($))} />
663            {muteButton}
664          </Box>
665          <Markdown text={`**${np.track || '(unknown track)'}**  ·  ${np.artist}${np.album ? ' · ' + np.album : ''}`} />
666          <Text dimColor wrap="truncate-end">{`${formatTime(np.positionSec)}  ${progressBar(np.positionSec, np.durationMs, 16)}  ${formatTime(np.durationMs / 1000)}`}</Text>
667        </Box>
668      </Box>
669    );
670  }
671
672  // compact mode trades the fixed narrow width for one wide row on purpose — that's the whole
673  // point of asking for one line instead of three, so only the stacked layout stays capped
674  return (
675    <Box flexDirection="column" width={compact && nowPlaying.running && !errorMessage ? undefined : WIDTH + (bandArt ? BAND_ART_COLUMNS + 2 : 0)}>
676      {ticker}
677      {content}
678    </Box>
679  );
680}
681
682// sidebar colors — raw hex, not theme keys, since the tint is meant to read as "Spotify" in either
683// terminal theme. The tint is deliberately near-black: anything lighter fights the text colors
684const SPOTIFY_GREEN = '#1DB954';
685// the pane's background: a vertical black → rust gradient. There's no gradient prop, so each drawn
686// line gets its own solid backgroundColor, stepped down the pane: `lineBg(n)` claims the next n
687// lines (in drawing order) and returns the color at their middle. Eased so the top — the art and
688// the search box — stays close to black and the rust builds toward the bottom
689const GRADIENT_FROM = [0x00, 0x00, 0x00];
690const GRADIENT_TO = [0x9c, 0x3f, 0x17]; // rust, dark enough that white text still reads on it
691let gradientRow = 0;
692let gradientRows = 1;
693
694function gradientAt(row: number): string {
695  const t = Math.pow(Math.min(1, Math.max(0, row / Math.max(1, gradientRows - 1))), 1.4);
696  return '#' + GRADIENT_FROM.map((from, i) => Math.round(from + (GRADIENT_TO[i] - from) * t).toString(16).padStart(2, '0')).join('');
697}
698
699// props for one full-width line of the pane (or an n-line block drawn as one solid band)
700function line(n = 1) {
701  const bg = gradientAt(gradientRow + (n - 1) / 2);
702  gradientRow += n;
703  return { backgroundColor: bg, paddingX: 1 } as const;
704}
705
706// the pane scrolls its own list, not the engine's window over the whole tree: the header (art,
707// search box, a view's title row) stays pinned, so the cover image never has to be re-placed as
708// you scroll — Ghostty redrawing a moving kitty image every wheel tick is what made scrolling
709// stutter — and the gradient stays anchored to the screen instead of scrolling away with the rows.
710// `ui.scroll` (below) moves `listOffset`; each view draws only the rows that fit under its header
711let listOffset = 0;
712let listMax = 0;
713let listView = '';
714let paneColumns = 60;
715
716// one list row is always one line — a label that wrapped would throw every row after it off the
717// gradient and off the window's row count
718function fit(label: string): string {
719  const room = Math.max(8, paneColumns - 2);
720  return label.length > room ? label.slice(0, room - 1) + '…' : label;
721}
722
723// `view` names what's listed; a new view starts back at its top
724function windowed(view: string, rows: Array<() => any>): any[] {
725  if (view !== listView) {
726    listView = view;
727    listOffset = 0;
728  }
729  const visible = Math.max(1, gradientRows - gradientRow);
730  listMax = Math.max(0, rows.length - visible);
731  listOffset = Math.min(listOffset, listMax);
732  return rows.slice(listOffset, listOffset + visible).map(row => row());
733}
734const ROW_HOVER = { backgroundColor: '#2a2a2a', color: SPOTIFY_GREEN } as const;
735
736// ---------- sidebar (Pane): playlists only, no player — that's the band's job ----------
737
738function renderFullscreen($: any, e: any, options: any) {
739  const { Box } = $.ui.resolve(e);
740  const bodyRows = Number(e.props?.scroll?.bodyRows) || 30;
741  gradientRow = 0;
742  gradientRows = bodyRows;
743  paneColumns = Number(e.props?.bodyColumns) || 60;
744  const header = renderNowPlayingHeader($, e);
745  const body = renderPaneBody($, e, options);
746  // empty lines to the bottom of the pane, so the gradient reaches it even under a short list
747  const filler = Array.from({ length: Math.max(0, bodyRows - gradientRow) }, (_, i) => (
748    <Box key={`pane:fill:${i}`} height={1} {...line()} />
749  ));
750  return (
751    // sized to the pane's own box: a Box only paints its background over the cells it occupies
752    <Box flexDirection="column" width={e.props?.bodyColumns}>
753      {header}
754      {body}
755      {filler}
756    </Box>
757  );
758}
759
760// the one bit of "player" the sidebar has: what's on, with its cover. Image is terminal-only
761// (the other surfaces' element tables don't have it), so elsewhere this is just the text
762function renderNowPlayingHeader($: any, e: any) {
763  if (!nowPlaying.running) return null;
764  const { Box, Text, Image } = $.ui.resolve(e);
765  const np = nowPlaying;
766  const showArt = e.surface === 'terminal' && art?.trackId === np.trackId && art.path;
767  return (
768    // the lines it claims must match what it draws, or the filler under the list comes up short
769    // and the gradient stops above the pane's bottom: 7 for the art, else its text lines, +1 gap
770    <Box flexDirection="row" columnGap={2} paddingBottom={1} {...line((showArt ? 7 : np.album ? 4 : 3) + 1)}>
771      {/* 14×7 cells reads as square: a terminal cell is about twice as tall as it is wide */}
772      {showArt && <Image key="pane:art" source={{ file: art!.path, format: 'png' }} columns={14} rows={7} alt={np.album || ' '} />}
773      <Box flexDirection="column" justifyContent="center">
774        <Text bold color={SPOTIFY_GREEN}>{np.state === 'playing' ? 'Now playing' : 'Paused'}</Text>
775        <Text bold wrap="truncate-end">{np.track || '(unknown track)'}</Text>
776        <Text wrap="truncate-end">{np.artist}</Text>
777        {np.album ? <Text dimColor wrap="truncate-end">{np.album}</Text> : null}
778      </Box>
779    </Box>
780  );
781}
782
783function renderPaneBody($: any, e: any, options: any) {
784  const { Box, Text, Button, Markdown, Input } = $.ui.resolve(e);
785
786  const afterPaneAction = (action: () => Promise<void>) => async () => {
787    try {
788      await action();
789    } catch (err: any) {
790      paneError = err?.message ?? String(err);
791    }
792    $.ui.invalidate('ui.render');
793  };
794
795  // no app of their own yet: the band and the header above still work, only the Web API needs this
796  if (!auth && !hasClientId) {
797    const steps = [
798      'To search and browse your playlists, connect',
799      'your own (free) Spotify app:',
800      '',
801      '1. developer.spotify.com/dashboard → Create app',
802      '   Redirect URI: http://127.0.0.1:8907/callback',
803      '   APIs: Web API',
804      '2. Copy its Client ID, then run:',
805      '   /spotify config <client-id>',
806    ];
807    return (
808      <Box flexDirection="column">
809        <Box {...line()}><Text bold color={SPOTIFY_GREEN}>Set up search & playlists</Text></Box>
810        {steps.map((t, i) => (
811          <Box key={`pane:setup:${i}`} {...line()}><Text dimColor={t.startsWith('   ')}>{t || ' '}</Text></Box>
812        ))}
813      </Box>
814    );
815  }
816
817  if (!auth) {
818    return (
819      <Box flexDirection="column" {...line(pendingLogin ? 8 : 2)}>
820        <Markdown text="**Connect Spotify** to browse and play your playlists." />
821        <Button key="pane:connect" label="connect Spotify" onPress={afterPaneAction(() => startLogin($, options))} />
822        {pendingLogin && (
823          <Box flexDirection="column">
824            <Text dimColor>
825              Approve in the browser — this connects on its own within a second or two once you do. If it doesn't
826              (no python3, or something else is using the port), paste the URL it redirected to below instead;
827              it'll look like the page failed to load, that's expected, the code is in the address bar.
828            </Text>
829            <Input
830              key="pane:login-url"
831              placeholder="http://127.0.0.1:8907/callback?code=..."
832              onSubmit={value => {
833                submitLoginUrl($, options, value).then(() => $.ui.invalidate('ui.render'));
834              }}
835            />
836          </Box>
837        )}
838        {loginError && <Text color="red">{loginError}</Text>}
839      </Box>
840    );
841  }
842
843  // a dismissible banner, not a screen that replaces navigation — a failed load anywhere used
844  // to hide the back button along with everything else, leaving no way out of a broken view
845  const errorBanner = () => paneError ? (
846    <Box flexDirection="row" columnGap={2} {...line()}>
847      <Text color="red" wrap="wrap">{paneError}</Text>
848      <Button key="pane:dismiss-error" label="✕" onPress={afterPaneAction(async () => { paneError = null; })} />
849    </Box>
850  ) : null;
851
852  if (searchQuery !== null && browseStack.length > 0) {
853    const frame = browseStack[browseStack.length - 1];
854    const it = frame.item;
855    const back = (
856      <Button key="pane:back-browse" label="‹" onPress={afterPaneAction(async () => { browseStack = browseStack.slice(0, -1); })} />
857    );
858    const view = `browse:${browseStack.length}:${it.uri}`;
859    // a function, not a tree: it has to run after the title row has claimed its gradient line
860    const body = () => it.kind === 'artist'
861      ? frame.albums === null ? <Box {...line()}><Text dimColor>loading…</Text></Box> : frame.albums.length === 0 ? <Box {...line()}><Text dimColor>no albums</Text></Box> : (
862          <Box flexDirection="column">
863            {windowed(view, frame.albums.map(a => () => (
864              <Box key={`row:browse:${a.uri}`} {...line()}>
865                <Button key={`browse:${a.uri}`} plain hover={ROW_HOVER} label={fit(`◉ ${a.name}`)} onPress={afterPaneAction(() => openSearchResult($, options, a))} />
866              </Box>
867            )))}
868          </Box>
869        )
870      : frame.tracks === null ? <Box {...line()}><Text dimColor>loading…</Text></Box> : frame.tracks.length === 0 ? <Box {...line()}><Text dimColor>no tracks</Text></Box> : (
871          <Box flexDirection="column">
872            {/* played inside its album/playlist (offset), so the rest of it follows on after */}
873            {windowed(view, frame.tracks.map(t => () => (
874              <Box key={`row:browse:${t.uri}`} {...line()}>
875                <Button
876                  key={`browse:${t.uri}`}
877                  plain
878                  hover={ROW_HOVER}
879                  label={fit(`▸ ${t.name} — ${t.artist}`)}
880                  onPress={afterPaneAction(() => playOnDevice($, options, { context_uri: it.uri, offset: { uri: t.uri } }))}
881                />
882              </Box>
883            )))}
884          </Box>
885        );
886    return (
887      <Box flexDirection="column">
888        <Box flexDirection="row" columnGap={2} {...line()}>
889          {back}
890          <Button key="pane:browse-play" label="▶" onPress={afterPaneAction(() => playContext($, options, it.uri, false))} />
891          <Button key="pane:browse-shuffle" label="🔀" onPress={afterPaneAction(() => playContext($, options, it.uri, true))} />
892          <Markdown text={`**${it.name}**  ·  ${it.detail}`} />
893        </Box>
894        {errorBanner()}
895        {body()}
896      </Box>
897    );
898  }
899
900  if (searchQuery !== null) {
901    const icon = { track: '▸', album: '◉', artist: '☺', playlist: '≡' } as const;
902    const sectionTitle = { track: 'Songs', album: 'Albums', artist: 'Artists', playlist: 'Playlists' } as const;
903    return (
904      <Box flexDirection="column">
905        <Box flexDirection="row" columnGap={2} {...line()}>
906          <Button
907            key="pane:back-search"
908            label="‹"
909            onPress={afterPaneAction(async () => {
910              searchQuery = null;
911              searchResults = null;
912            })}
913          />
914          <Markdown text={`**Search:** ${searchQuery}`} />
915        </Box>
916        {errorBanner()}
917        {searchResults === null ? (
918          <Box {...line()}><Text dimColor>searching…</Text></Box>
919        ) : searchResults.length === 0 ? (
920          <Box {...line()}><Text dimColor>no results</Text></Box>
921        ) : (
922          <Box flexDirection="column">
923            {/* a heading per kind (in toSearchResults' order), skipped when that kind came back empty;
924                headings are rows of the same scrolled list, so they scroll away with their section */}
925            {windowed(
926              `search:${searchQuery}`,
927              (['track', 'album', 'artist', 'playlist'] as const).flatMap(kind => {
928                const rows = searchResults!.filter(r => r.kind === kind);
929                if (rows.length === 0) return [];
930                return [
931                  () => <Box key={`section:${kind}`} {...line()}><Text bold color={SPOTIFY_GREEN}>{sectionTitle[kind]}</Text></Box>,
932                  ...rows.map(r => () => (
933                    <Box key={`row:result:${r.uri}`} {...line()}>
934                      <Button
935                        key={`result:${r.uri}`}
936                        plain
937                        hover={ROW_HOVER}
938                        label={fit(`${icon[r.kind]} ${r.name} — ${r.detail}${canOpen(r) ? '  ›' : ''}`)}
939                        onPress={afterPaneAction(() => (canOpen(r) ? openSearchResult($, options, r) : playSearchResult($, options, r)))}
940                      />
941                    </Box>
942                  )),
943                ];
944              }),
945            )}
946          </Box>
947        )}
948      </Box>
949    );
950  }
951
952  if (selectedPlaylist) {
953    const playlist = selectedPlaylist;
954    return (
955      <Box flexDirection="column">
956        <Box flexDirection="row" columnGap={2} {...line()}>
957          <Button
958            key="pane:back-playlists"
959            label="‹"
960            onPress={afterPaneAction(async () => {
961              selectedPlaylist = null;
962              playlistTracks = null;
963            })}
964          />
965          <Button key="pane:play" label="▶" onPress={afterPaneAction(() => playPlaylist($, options, playlist.id, false))} />
966          <Button key="pane:shuffle-play" label="🔀" onPress={afterPaneAction(() => playPlaylist($, options, playlist.id, true))} />
967          <Markdown text={`**${playlist.name}**`} />
968        </Box>
969        {errorBanner()}
970        {playlistTracks === null ? (
971          <Box {...line()}><Text dimColor>loading…</Text></Box>
972        ) : (
973          <Box flexDirection="column">
974            {windowed(`playlist:${playlist.id}`, playlistTracks.map(t => () => (
975              <Box key={`row:track:${t.uri}`} {...line()}>
976                <Button
977                  key={`track:${t.uri}`}
978                  plain
979                  hover={ROW_HOVER}
980                  label={fit(`▸ ${t.name} — ${t.artist}`)}
981                  onPress={afterPaneAction(() => playTrack($, options, t.uri))}
982                />
983              </Box>
984            )))}
985          </Box>
986        )}
987      </Box>
988    );
989  }
990
991  return (
992    <Box flexDirection="column">
993      <Box {...line(3)}>
994      <Box borderStyle="round" borderColor={SPOTIFY_GREEN} paddingX={1} flexGrow={1}>
995        <Input
996          key="pane:search"
997          placeholder="search songs, albums, artists, playlists…"
998          onSubmit={value => {
999            const q = value.trim();
1000            if (!q) return;
1001            runSearch($, options, q)
1002              .catch((err: any) => {
1003                searchResults = [];
1004                paneError = err?.message ?? String(err);
1005              })
1006              .then(() => $.ui.invalidate('ui.render'));
1007          }}
1008        />
1009      </Box>
1010      </Box>
1011      <Box {...line()}><Text bold color={SPOTIFY_GREEN}>Playlists</Text></Box>
1012      {errorBanner()}
1013      {ensurePlaylistsLoading($, options)}
1014      {playlists === null ? (
1015        <Box {...line()}><Text dimColor>loading…</Text></Box>
1016      ) : playlists.length === 0 ? (
1017        <Box {...line()}><Text dimColor>no playlists</Text></Box>
1018      ) : (
1019        <Box flexDirection="column">
1020          {windowed('playlists', playlists.map(p => () => (
1021            <Box key={`row:playlist:${p.id}`} {...line()}>
1022              <Button
1023                key={`playlist:${p.id}`}
1024                plain
1025                hover={ROW_HOVER}
1026                label={fit(p.name)}
1027                onPress={afterPaneAction(async () => {
1028                  selectedPlaylist = p;
1029                  playlistTracks = null;
1030                  $.ui.invalidate('ui.render');
1031                  await loadPlaylistTracks($, options, p.id);
1032                })}
1033              />
1034            </Box>
1035          )))}
1036        </Box>
1037      )}
1038    </Box>
1039  );
1040}
1041
1042// ---------- wiring ----------
1043
1044async function handleCommandRun($: any, e: any, options: any): Promise<{ text?: string }> {
1045  const raw = (e.args as string).trim();
1046  const arg = raw.toLowerCase();
1047  if (arg === 'config' || arg.startsWith('config ')) {
1048    const id = raw.slice('config'.length).trim();
1049    if (!id) {
1050      const current = await clientIdFrom($, options);
1051      return {
1052        text: current
1053          ? `$Spotify Client ID: ${current} · /spotify config <client-id> to change it`
1054          : '$Spotify has no Client ID yet · create an app at developer.spotify.com/dashboard, then /spotify config <client-id>',
1055      };
1056    }
1057    if (!isClientId(id)) return { text: `$Spotify: "${id}" isn't a Client ID — it's the 32-character hex string on your app's page` };
1058    await $.store.set(CLIENT_ID_STORE_KEY, id);
1059    // tokens belong to the app that issued them, so a new app means logging in again
1060    await logout($);
1061    hasClientId = true;
1062    $.ui.invalidate('ui.render');
1063    return { text: '$Spotify Client ID saved · open ◫ and press "connect Spotify" to log in' };
1064  }
1065  if (arg === 'stop' || arg === 'close') {
1066    open = false;
1067    $.ui.invalidate('ui.render');
1068    return { text: '$Spotify closed' };
1069  }
1070  if (arg === 'logout' || arg === 'disconnect') {
1071    await logout($);
1072    $.ui.invalidate('ui.render');
1073    return { text: '$Spotify disconnected — press "connect Spotify" in the sidebar to log in again' };
1074  }
1075
1076  open = true;
1077  await refreshNowPlaying($);
1078  $.ui.invalidate('ui.render');
1079  return { text: '$Spotify · controls above the prompt, ⛶ for playlists · Esc returns to it · /spotify stop closes' };
1080}
1081
1082async function handleAbovePromptRender($: any, e: any, next: any, options: any) {
1083  if (!open || e.props.hasSurvey || e.surface !== 'terminal') return next(e);
1084  const { Box } = $.ui.resolve(e);
1085  // next to whatever else draws in the band, not stacked below it — the mod is capped to WIDTH
1086  // precisely so this row has room for both
1087  return (
1088    <Box flexDirection="row" columnGap={2}>
1089      {renderBand($, e, options)}
1090      {await next(e)}
1091    </Box>
1092  );
1093}
1094
1095async function handlePaneRender($: any, e: any, next: any, options: any) {
1096  if (e.requestId !== PANE_ID) return next(e);
1097  if (!auth) await loadAuth($); // same reload case as ensurePlaylistsLoading: module state is gone, the store isn't
1098  if (!auth) hasClientId = !!(await clientIdFrom($, options));
1099  return renderFullscreen($, e, options);
1100}
1101
1102// the pane's own scrolling (see `windowed`): no `next`, so the engine's window never moves and
1103// the header stays put; the list moves by the wheel's/keys' `by` instead
1104async function handlePaneScroll($: any, e: any, next: any) {
1105  if (e.requestId !== PANE_ID) return next(e);
1106  const to = Math.min(listMax, Math.max(0, listOffset + (Number(e.by) || 0)));
1107  if (to !== listOffset) {
1108    listOffset = to;
1109    $.ui.invalidate('ui.render');
1110  }
1111  return {};
1112}
1113
1114async function handleUiMessage($: any, e: any, next: any, options: any) {
1115  const data = e.data as { tick?: unknown } | null;
1116  if (!data?.tick) return next(e);
1117  await refreshNowPlaying($);
1118  await refreshArt($);
1119  if (pendingLogin) await checkLoginCallback($, options);
1120  $.ui.invalidate('ui.render');
1121  return { props: {} };
1122}
1123
1124async function handleSessionStart($: any, e: any, next: any) {
1125  const r = await next(e);
1126  await loadAuth($);
1127  await $.command
1128    .register({
1129      name: 'spotify',
1130      description: '$Spotify controls above the prompt (stop closes, logout resets the connection)',
1131      argumentHint: '[stop|logout|config <client-id>]',
1132      immediate: true,
1133    })
1134    .catch((err: any) => $.ui.log(`spotify: /spotify not registered: ${err}`));
1135  return r;
1136}
1137
1138export const register: Register = (on, options) => {
1139  on('session.start', handleSessionStart);
1140  on('command.run', { command: 'spotify' }, ($, e) => handleCommandRun($, e, options));
1141  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => handleAbovePromptRender($, e, next, options));
1142  on('ui.render', { component: 'Pane' }, ($, e, next) => handlePaneRender($, e, next, options));
1143  on('ui.message', ($, e, next) => handleUiMessage($, e, next, options));
1144  on('ui.scroll', { component: 'Pane' }, ($, e, next) => handlePaneScroll($, e, next));
1145};
1146
hooks/lib/applescript.ts 58 lines
1// Pure parsing/formatting only — nothing here touches `$`. Actually shelling out to `osascript`
2// lives in register.tsx (the validator requires every `$` call to be textually in the hooks
3// module file, not reached through an import).
4
5export type PlayerState = 'playing' | 'paused' | 'stopped';
6
7export type NowPlaying =
8  | { running: false }
9  | {
10      running: true;
11      trackId: string; // bare id, "37i9..." — the "spotify:track:" prefix stripped
12      track: string;
13      artist: string;
14      album: string;
15      durationMs: number;
16      positionSec: number;
17      state: PlayerState;
18      volume: number;
19    };
20
21// the AppleScript below joins fields with ASCII 31 (a unit separator no track/artist name uses)
22export const FIELD_SEP = '\x1f';
23
24export function parseNowPlaying(raw: string): NowPlaying {
25  const trimmed = raw.trim();
26  if (trimmed === 'not running' || trimmed === '') return { running: false };
27
28  const [trackId, track, artist, album, durationMs, positionSec, state, volume] = trimmed.split(FIELD_SEP);
29  const validState: PlayerState = state === 'playing' || state === 'paused' ? (state as PlayerState) : 'stopped';
30
31  return {
32    running: true,
33    trackId: (trackId ?? '').replace('spotify:track:', ''),
34    track: track ?? '',
35    artist: artist ?? '',
36    album: album ?? '',
37    durationMs: Number(durationMs) || 0,
38    positionSec: Number(positionSec) || 0,
39    state: validState,
40    volume: Number(volume) || 0,
41  };
42}
43
44export function formatTime(totalSeconds: number): string {
45  const s = Math.max(0, Math.floor(totalSeconds));
46  const m = Math.floor(s / 60);
47  const rem = s % 60;
48  return `${m}:${rem.toString().padStart(2, '0')}`;
49}
50
51// a text progress bar: filled cells up to the playback fraction, out of `width` cells total
52export function progressBar(positionSec: number, durationMs: number, width = 24): string {
53  const durationSec = durationMs / 1000;
54  const fraction = durationSec > 0 ? Math.min(1, Math.max(0, positionSec / durationSec)) : 0;
55  const filled = Math.round(fraction * width);
56  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled));
57}
58
hooks/lib/spotifyAuth.ts 157 lines
1// Pure PKCE/URL helpers only — nothing here touches `$`. The actual browser-open and token-fetch
2// calls live in register.tsx. `crypto`, `atob`/`btoa`, `TextEncoder` and `URL` are runtime
3// globals, not the `$` API, so they're fine to use here.
4
5// a loopback redirect URI never needs a real listener for Spotify's side of things (it allows an
6// unregistered one, and the code lands in the address bar regardless) — but catching it
7// automatically instead of asking for a copy-paste does need something briefly listening there
8export const CALLBACK_PORT = 8907;
9export const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}/callback`;
10
11// there's no shared Client ID: a Spotify app in Development Mode only answers the Web API for
12// its owner and up to 5 registered users, so a shared one could never serve everyone. Each person
13// brings their own app's ID (`/spotify config <id>`); as its owner they're let in automatically.
14// Spotify Client IDs are 32 hex characters
15export function isClientId(value: string): boolean {
16  return /^[0-9a-f]{32}$/i.test(value);
17}
18
19export const SCOPES = [
20  'user-read-playback-state',
21  'user-modify-playback-state',
22  'user-read-currently-playing',
23  'playlist-read-private',
24  'playlist-read-collaborative',
25  'user-library-read', // Liked Songs — reading only, there's no "like" button anymore
26].join(' ');
27
28function base64url(bytes: Uint8Array): string {
29  let binary = '';
30  for (const b of bytes) binary += String.fromCharCode(b);
31  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
32}
33
34export function randomVerifier(): string {
35  const bytes = new Uint8Array(64);
36  crypto.getRandomValues(bytes);
37  return base64url(bytes);
38}
39
40export function randomState(): string {
41  return crypto.randomUUID();
42}
43
44export async function challengeFor(verifier: string): Promise<string> {
45  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
46  return base64url(new Uint8Array(digest));
47}
48
49export function authUrl(clientId: string, challenge: string, state: string): string {
50  const params = new URLSearchParams({
51    client_id: clientId,
52    response_type: 'code',
53    redirect_uri: REDIRECT_URI,
54    code_challenge_method: 'S256',
55    code_challenge: challenge,
56    scope: SCOPES,
57    state,
58  });
59  return `https://accounts.spotify.com/authorize?${params.toString()}`;
60}
61
62// accepts either the full redirected URL (the normal case — pasted straight from the address
63// bar) or a bare authorization code, so a fumbled copy still works
64export function extractCode(pasted: string, expectedState: string): string {
65  const trimmed = pasted.trim();
66  if (!trimmed.includes('://')) {
67    if (!trimmed) throw new Error('nothing pasted');
68    return trimmed;
69  }
70
71  const url = new URL(trimmed);
72  const code = url.searchParams.get('code');
73  const state = url.searchParams.get('state');
74  const error = url.searchParams.get('error');
75  if (error) throw new Error(`Spotify denied the request: ${error}`);
76  if (!code) throw new Error('that URL has no "code" in it — paste the one from right after you clicked Agree');
77  if (state !== expectedState) throw new Error('that code is from an older /spotify login — run login again and paste the newest one');
78  return code;
79}
80
81// where the one-shot local listener (below) drops what it caught, and the script that runs it —
82// state-scoped so a stale file from an earlier, abandoned login is never mistaken for this one
83export function callbackFilePath(state: string): string {
84  return `/tmp/spotify-mod-callback-${state}.json`;
85}
86
87export function serverScriptFilePath(state: string): string {
88  return `/tmp/spotify-mod-server-${state}.py`;
89}
90
91// a short-lived HTTP server: takes the redirect Spotify sends, writes its code/state/error to
92// CALLBACK_FILE as JSON, answers the browser with a page saying to come back, and stops —
93// python3 is reliably preinstalled on macOS, so this needs no compiled helper. `$` has no
94// long-lived-process primitive (`process.run` is one-shot, resolves only once its child exits),
95// so this is spawned backgrounded (`nohup ... & disown`) from a shell that itself exits right
96// away, and the hooks module polls for the file it wrote on the ticker's existing once-a-second
97// tick instead of waiting on the listener directly.
98//
99// It loops `handle_request()` rather than answering exactly once: a single-shot server that
100// exits after the very first connection is gone by the time the real OAuth redirect lands if
101// anything else reaches the port first (a browser's speculative preconnect, a stray favicon
102// fetch) — which reads to the person as the page hanging ("took too long to respond"), not an
103// instant, expected "can't reach this page". Requests that aren't the real callback are answered
104// (so nothing hangs) but don't stop the loop; only one carrying `code` or `error` does. The file
105// write is wrapped so a failure there (e.g. a sandboxed environment that can't write to /tmp)
106// still can't prevent the HTTP response from going out.
107export function loopbackServerScript(state: string): string {
108  const filePath = callbackFilePath(state);
109  return [
110    'import http.server, json, time, urllib.parse',
111    `PATH = ${JSON.stringify(filePath)}`,
112    'def page(ok):',
113    '    if ok:',
114    "        icon, title, sub = '\\u2713', 'Connected to Claude Code', 'You can close this tab now.'",
115    '    else:',
116    "        icon, title, sub = '\\u2715', 'Connection failed', 'Go back to Claude Code and try again.'",
117    "    return ('<!doctype html><html><head><meta charset=\"utf-8\"><title>' + title + '</title><style>'"
118      + " 'body{background:#121212;color:#fff;font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;'"
119      + " 'display:flex;align-items:center;justify-content:center;height:100vh;margin:0}'"
120      + " '.card{text-align:center}'"
121      + " '.icon{width:56px;height:56px;border-radius:50%;display:flex;align-items:center;justify-content:center;'"
122      + " 'margin:0 auto 20px;font-size:26px;font-weight:bold}'"
123      + " '.icon.ok{background:#1db954;color:#000}' '.icon.err{background:#e22134;color:#fff}'"
124      + " 'h1{font-size:18px;font-weight:600;margin:0 0 6px}' 'p{color:#b3b3b3;font-size:13px;margin:0}'"
125      + " '</style></head><body><div class=\"card\"><div class=\"icon ' + ('ok' if ok else 'err') + '\">'"
126      + " + icon + '</div><h1>' + title + '</h1><p>' + sub + '</p></div>'"
127      + " '<script>setTimeout(function(){window.close()},1500)</script></body></html>')",
128    'class H(http.server.BaseHTTPRequestHandler):',
129    '    def do_GET(self):',
130    '        q = urllib.parse.urlparse(self.path)',
131    '        p = urllib.parse.parse_qs(q.query)',
132    "        code = p.get('code', [''])[0]",
133    "        state = p.get('state', [''])[0]",
134    "        error = p.get('error', [''])[0]",
135    '        is_callback = bool(code or error)',
136    '        self.send_response(200)',
137    "        self.send_header('Content-Type', 'text/html')",
138    '        self.end_headers()',
139    "        self.wfile.write(page(bool(code)).encode('utf-8') if is_callback else b'')",
140    '        if is_callback:',
141    '            try:',
142    "                open(PATH, 'w').write(json.dumps({'code': code, 'state': state, 'error': error}))",
143    '            except Exception:',
144    '                pass',
145    '            self.server.got_it = True',
146    '    def log_message(self, *a):',
147    '        pass',
148    `srv = http.server.HTTPServer(('127.0.0.1', ${CALLBACK_PORT}), H)`,
149    'srv.got_it = False',
150    'deadline = time.time() + 180',
151    'while not srv.got_it and time.time() < deadline:',
152    '    srv.timeout = max(1, deadline - time.time())',
153    '    srv.handle_request()',
154    'srv.server_close()',
155  ].join('\n');
156}
157
hooks/lib/spotifyApi.ts 86 lines
1// Pure response shaping only — nothing here touches `$` or does any fetching (that's in
2// register.tsx). Takes already-parsed JSON, returns the plain shapes the Pane renders.
3
4export type TokenSet = {
5  accessToken: string;
6  refreshToken: string; // Spotify only sends a new one sometimes; keep the last one otherwise
7  expiresAt: number; // epoch ms
8};
9
10export function tokenSetFrom(json: any, previousRefreshToken: string): TokenSet {
11  return {
12    accessToken: json.access_token,
13    refreshToken: json.refresh_token ?? previousRefreshToken,
14    expiresAt: Date.now() + (Number(json.expires_in) || 3600) * 1000,
15  };
16}
17
18export type Playlist = { id: string; name: string };
19
20// only playlists this account owns — a followed, collaborative-but-not-yours, or algorithmic
21// playlist (Discover Weekly, a Blend, ...) shows up in /me/playlists just like an owned one, but
22// its tracks always 403 now (Spotify's Feb 2026 API change, see register.tsx). Filtering here
23// means the sidebar only ever lists things it can actually open, instead of listing broken links.
24export function toPlaylists(json: any, ownerId: string): Playlist[] {
25  const items = Array.isArray(json?.items) ? json.items : [];
26  return items
27    .filter((p: any) => p && p.id && p.owner?.id === ownerId)
28    .map((p: any) => ({ id: p.id, name: p.name ?? '(untitled)' }));
29}
30
31export type PlaylistTrack = { uri: string; name: string; artist: string };
32
33// Fisher-Yates, used for shuffle-playing Liked Songs — that has no `context_uri` of its own to
34// hand Spotify's own shuffle to (it isn't a playlist resource), so this mod does it client-side
35// over whatever page of tracks it has loaded.
36export function shuffled<T>(items: readonly T[]): T[] {
37  const copy = items.slice();
38  for (let i = copy.length - 1; i > 0; i--) {
39    const j = Math.floor(Math.random() * (i + 1));
40    [copy[i], copy[j]] = [copy[j], copy[i]];
41  }
42  return copy;
43}
44
45export function toPlaylistTracks(json: any): PlaylistTrack[] {
46  const items = Array.isArray(json?.items) ? json.items : [];
47  return items
48    // `item` is the current field (a TrackObject or EpisodeObject); `track` is Spotify's own
49    // deprecated alias for the same thing, kept here only as a fallback for an older response
50    // an album's /tracks items are the track objects themselves, with no wrapper at all
51    .map((it: any) => it.item ?? (typeof it.track === 'object' ? it.track : it))
52    .filter((t: any) => t && t.uri)
53    .map((t: any) => ({
54      uri: t.uri,
55      name: t.name ?? '(untitled)',
56      artist: (t.artists ?? []).map((a: any) => a.name).join(', '),
57    }));
58}
59
60// one row of /v1/search, flattened across the types the sidebar asks for. `kind` picks how it
61// plays: a track goes in as `uris`, an album/artist/playlist as its own `context_uri`
62// `ownerId` is only set for playlists: only an owned one can be opened (see register.tsx)
63export type SearchResult = { kind: 'track' | 'album' | 'artist' | 'playlist'; id: string; uri: string; name: string; detail: string; ownerId?: string };
64
65const joinArtists = (x: any) => (x?.artists ?? []).map((a: any) => a.name).join(', ');
66
67// tracks first — the thing a search is usually after — then albums, artists, playlists.
68// Spotify pads `items` with nulls for results it won't return, hence the filter
69export function toSearchResults(json: any): SearchResult[] {
70  const pick = (key: string, kind: SearchResult['kind'], detail: (x: any) => string): SearchResult[] =>
71    (Array.isArray(json?.[key]?.items) ? json[key].items : [])
72      .filter((x: any) => x && x.uri)
73      .map((x: any) => ({ kind, id: x.id, uri: x.uri, name: x.name ?? '(untitled)', detail: detail(x), ...(kind === 'playlist' ? { ownerId: x.owner?.id } : {}) }));
74  return [
75    ...pick('tracks', 'track', joinArtists),
76    ...pick('albums', 'album', joinArtists),
77    ...pick('artists', 'artist', () => 'artist'),
78    ...pick('playlists', 'playlist', (p: any) => `by ${p.owner?.display_name ?? p.owner?.id ?? 'unknown'}`),
79  ];
80}
81
82// /artists/{id}/albums — the one artist listing left after Spotify dropped top-tracks (Feb 2026)
83export function toArtistAlbums(json: any): SearchResult[] {
84  return toSearchResults({ albums: json });
85}
86
hooks/boards/ticker.tsx 19 lines
1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3
4// Draws nothing — its only job is a free-running clock. The hooks module has no timer of its
5// own (only a Client's drawing-thread `surface.every` does), so this is how the "now playing"
6// card gets a heartbeat: post up to `ui.message` once a second, which re-runs the AppleScript
7// query and invalidates the render.
8
9export default function Ticker(_props: unknown, surface: ClientSurface<{ started: true }>) {
10  const { Box } = surface.elements
11  if (surface.state === undefined) {
12    surface.setState({ started: true })
13    surface.every(1000, () => {
14      surface.post({ tick: true })
15    })
16  }
17  return <Box />
18}
19