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

<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.
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
/spotify opens the band (/spotify stop closes it).✕ 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.⌄ 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.open Spotify button instead of controls.◫ 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.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.
http://127.0.0.1:8907/callback and tick Web API./spotify config <client-id>. It's saved for every session. (/spotify config on its own shows the current one.)◫ 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.
osascript (AppleScript) — no login, no API keys.{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.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.⌄/⌃) 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.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.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.$.store across sessions; the access token silently refreshes on expiry (or on a 401), once per call, before failing for real./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./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./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./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.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 — the $ API is early access and may change between releases.python3 and a free port 8907 — if either's missing, connecting falls back to copy-pasting the redirect URL by hand.Client's own timer — not sample-accurate, but close enough to read at a glance.hooks/register.tsx 1146 lines1/* @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};
1146hooks/lib/applescript.ts 58 lines1// 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}
58hooks/lib/spotifyAuth.ts 157 lines1// 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}
157hooks/lib/spotifyApi.ts 86 lines1// 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}
86hooks/boards/ticker.tsx 19 lines1/* @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