Shows the track playing in Apple Music or Spotify above the prompt, with cover art, play/pause, skip, volume, shuffle and repeat controls, a share-link copy…

A Claude Code mod that shows what Apple Music or Spotify is playing in a slim band above the prompt, with controls, cover art, an "Up next" pane, lyrics, a listening history, and tools that let Claude control the player and build playlists from your Music library by mood.
<img width="1794" height="233" alt="compact_bottom_and_band" src="https://github.com/user-attachments/assets/f2ba37be-c97e-484f-8b4b-1ba946252977" />
The frame is red for Music and green for Spotify; the cover is the real picture in terminals that draw them (kitty, Ghostty, WezTerm) and a coloured half-block thumbnail elsewhere. The compact band, two rows with a small cover, is a setting away and is what a short or narrow terminal gets:
<img width="1794" height="147" alt="large_top_only" src="https://github.com/user-attachments/assets/8c35244b-f542-44ca-a88a-f4142b927b8f" />
Footer placement puts the track among the prompt footer's bottom-right labels instead:
<img width="1797" height="221" alt="bottom_only" src="https://github.com/user-attachments/assets/dc56c1bc-c577-497f-b13f-345119acb114" />
/np queue opens a pane in three sections. Up next is what Music will play, read only; the playlist is the mod's own, where every add lands; the add section searches your library and Apple Music:
Up next · OK Computer x: close
Airbag — Radiohead
▶ Paranoid Android — Radiohead
Subterranean Homesick Alien — Radiohead
Exit Music (For a Film) — Radiohead
Claude Code playlist · 10 tracks l: play it clear
The Journey — Tom Misch remove
Wander With Me (feat. Carmody) — Tom Misch remove
Falafel — Tom Misch remove
Beautiful Escape — Tom Misch remove
Add to the Claude Code playlist
Added tracks go in the playlist, not Up next; "play it" makes it Up next.
Only songs already in your Music library can be added; others open in…
┌──────────────────────────────────────────────────────────────────────┐
│ Search your library and Apple Music search │
└──────────────────────────────────────────────────────────────────────┘
"so what" clear
In your library, press one to add it to the playlist:
+ So What — Miles Davis · Kind of Blue
On Apple Music but not in your library, so not addable here.
Press one to open it in Music and add it to your library there:
↗ So What (Live) — Miles Davis · Live in Europe
/np lyrics follows the track on:
Lyrics · Everything In Its Right Place — Radiohead x: close
Everything
Everything
Everything
In its right place
macOS only: it talks to the players through AppleScript.
focus and similar labels sit./np queue or /np upnext), in three sections: Up next, Music's current playlist around the track on, each entry clickable to jump to it, preloaded in the background as tracks change; the mod's own playlist ("Claude Code" by default), with a play-it button and its last tracks; and Add to the playlist, a search box. See "How the pieces fit" below.now_playing (track, up next, lyrics), control_player, search_library (text search, or filters by genre, year, stars, favourite, play count and days since last play, sorted random, least played and so on), add_tracks (by name or by id), remove_tracks, clear_playlist, listening_history, open_in_music and show_now_playing. For a song you don't own it gives the Apple Music link, which open_in_music (or /np open <link>) opens in the Music app./np lyrics): the lyrics stored with the library track that is on, following the track as it changes. Apple Music's own streamed lyrics are not exposed to scripts, so only tracks whose files carry lyrics show them./np history [n]): every track heard while Claude Code was open, kept across sessions, with the time and the player. Claude can read it to find "that song from earlier" or build a playlist from a day's listening./config if you prefer.Load it for one session:
claude --plugin-dir /path/to/nowplaying
Or for every session, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.
The first time it talks to Music or Spotify, macOS asks whether your terminal may control that app. Allow it; otherwise the band shows a line pointing at System Settings → Privacy & Security → Automation.
add_tracks put songs. Press "play it" (or /np playlist) to make it what's playing; from then on it is Up next, and every song added joins the end of the queue. The pane's add section says which of the two is true at the moment. /np clear empties it; the songs stay in your library./np add and Claude can add. A song found on Apple Music that you don't own is shown with an open-in-Music row instead: open it there, add it to your library, and it becomes addable./np on its own reports what's on. With an argument:
| Argument | Does |
|---|---|
play, pause, toggle, next, prev | Transport |
play <name> | Music plays the first library track whose name matches |
vol <0-100>, vol +, vol - | Volume |
mute, shuffle, repeat | Toggle mute, toggle shuffle, cycle repeat |
copy | Copy the Spotify link, or the track name on Music |
open | Bring the player to the front |
queue, upnext | Open or close the Up next & playlist pane |
lyrics | Open or close the lyrics pane |
history [n] | The last n tracks heard (default 10) |
search <text> | Search the library and Apple Music, results in the pane |
add <title — artist> | Add the best library match to the playlist |
open <link> | Open a music.apple.com link in the Music app |
playlist | Play the mod's playlist |
clear | Empty the mod's playlist (the tracks stay in your library; the pane has clear and per-track remove buttons too) |
hide, show | Hide or show the band (remembered) |
play <name>, search, add, playlist, clear, lyrics and the contents of the pane need Apple Music running; see the table under Limits.
The band starts hidden. /np show, or asking Claude to show what's playing, reveals it, and that choice is remembered across sessions; /np hide or the x control hides it again.
In /config, under the plugin: placement (band, footer, both), band size (large, compact), player (auto, music, spotify), refresh interval, volume step, cover art, covers in Up next, preload Up next, quiet while Claude works, search Apple Music too, instant updates, tell Claude what's playing, and the playlist name.
| Apple Music | Spotify | |
|---|---|---|
| Band: track, progress, cover art | yes | yes |
| Play, pause, skip, volume, mute, shuffle | yes | yes |
| Repeat | off, all, one | off, all |
| Copy | the track name | a share link |
| Up next pane | yes | no queue in its scripting |
| Play by name, search | library only | no |
| Add to the playlist, play the playlist, clear it | library tracks only | no |
| Open a link in the app | music.apple.com links | no |
| Lyrics | stored with library tracks only | no |
| Filter by genre, year, stars, favourite, play count | yes (favourite needs macOS 14 or later) | no |
| Listening history | yes | yes |
| Instant updates | yes | yes |
Checks: claude plugin validate ., tsc -p . (after the plugin has loaded once, which lays the engine's types beside it) and claude plugin test .. The same three run in CI (.github/workflows/ci.yml, on macOS) and as pre-commit hooks alongside hygiene and secret scanning (.pre-commit-config.yaml; install with uvx pre-commit install).
The player-event helper is hooks/player-events.swift, run through swift as a script, so there is nothing to compile or ship.
claude plugin validate . # what the engine will accept, and what it refuses
claude plugin test . # the tests in tests/
tsc -p . # type-check, once Claude Code has laid .claude-plugin/types/ by loading the mod
The AppleScript is generated in hooks/players.ts. Quirks it works around: offset, names, removed, matched, before and after are reserved words; loved became favorited in macOS 14; properties must be fetched with get before being concatenated or written; and Music's index of a track matches track k of playlist but not a range or every track, so the queue reads tracks one by one.
hooks/register.tsx 2316 lines1/**
2 * Now Playing: the track on in Apple Music or Spotify, shown above the prompt
3 * (a band with cover art and controls) or in the prompt footer, with a `/np`
4 * command, an "Up next" pane that searches the library and adds to a playlist
5 * of the mod's own, and tools Claude calls to add songs.
6 */
7import { atom, read, update } from 'claude-code'
8import type { Elements, EngineInterface, PluginOptions, Register, RenderSurface, Timer } from 'claude-code'
9
10import type { Artwork, HistoryEntry, LibrarySearch, LyricsView, ModPlaylist, NowPlaying, PlayerSource, PlayerStatus, Queue, QueueEntry, SearchResult, Thumbnail } from '../types'
11import { BUTTON_GAP, MS_PER_SECOND, cellWidth, fitControls, formatClock, layoutFor, plainButtonWidth, positionNow, progressBar, truncateText } from './format'
12import { bytesFromBase64, decodeBmp, encodeCells, thumbnailCells } from './pixels'
13import {
14 MAX_SEARCH_RESULTS,
15 addTracksScript,
16 clearPlaylistScript,
17 filterLibraryScript,
18 lyricsScript,
19 normalise,
20 parseCount,
21 parseLibraryTracks,
22 parseLyrics,
23 pickTracks,
24 removeFromPlaylistScript,
25 artworkByIdScript,
26 artworkExportScript,
27 clampVolume,
28 classifyFailure,
29 controlScript,
30 describeFailure,
31 displayName,
32 downloadArgv,
33 ensurePlaylistScript,
34 nextRepeatMode,
35 openAppArgv,
36 osascriptArgv,
37 parseAdded,
38 parseCleared,
39 parsePlayed,
40 parseQueue,
41 parseRunningPlayers,
42 parseSearch,
43 parseSearchMany,
44 parsePlaylist,
45 playPlaylistTrackScript,
46 catalogueSearchUrl,
47 musicAppUrl,
48 parseCatalogue,
49 storefrontFrom,
50 withoutLibraryDuplicates,
51 parseStatus,
52 pickBestMatch,
53 pickCurrent,
54 playByNameScript,
55 playPlaylistScript,
56 queueScript,
57 runningPlayersArgv,
58 searchLibraryManyScript,
59 searchLibraryScript,
60 splitTrackQuery,
61 statusScript,
62 toPngArgv,
63 toThumbnailArgv,
64 type AddOutcome,
65 type LibraryFilter,
66 type LibrarySort,
67 type LibraryTrack,
68 type PlayerCommand,
69} from './players'
70
71const COMMAND = 'np'
72const QUEUE_PANE = 'now-playing-queue'
73/** Set once the person (or Claude) shows the band; unset, the band stays hidden. */
74const STORE_SHOWN = 'isShown'
75const TOOL_SEARCH = 'mcp__now-playing__search_library'
76const TOOL_ADD = 'mcp__now-playing__add_tracks'
77const TOOL_OPEN = 'mcp__now-playing__open_in_music'
78const TOOL_SHOW = 'mcp__now-playing__show_now_playing'
79const TOOL_CONTROL = 'mcp__now-playing__control_player'
80const TOOL_STATUS = 'mcp__now-playing__now_playing'
81const TOOL_HISTORY = 'mcp__now-playing__listening_history'
82const TOOL_REMOVE = 'mcp__now-playing__remove_tracks'
83const TOOL_CLEAR = 'mcp__now-playing__clear_playlist'
84const LYRICS_PANE = 'now-playing-lyrics'
85/** Set once the first session has been told the band exists and how to show it. */
86const STORE_INTRODUCED = 'isIntroduced'
87/** The tracks heard, oldest first, kept across sessions. */
88const STORE_HISTORY = 'history'
89const HISTORY_KEEP = 500
90const HISTORY_DEFAULT_SHOWN = 10
91const HISTORY_MAX_SHOWN = 100
92/** The same track starting again within this long is one listen, not two. */
93const HISTORY_REPEAT_WINDOW_MS = 30 * 60 * 1000
94const DEFAULT_UP_NEXT_SHOWN = 5
95const MAX_FILTER_RESULTS = 100
96/** While the player-event listener runs, polling only re-syncs the clock. */
97const LISTENER_PLAYING_POLL_MS = 15_000
98const LISTENER_PAUSED_POLL_MS = 60_000
99
100const IDLE_STATUS: PlayerStatus = { current: null, failure: null }
101const status = atom({ plugin: 'now-playing', key: 'status' } as const, IDLE_STATUS)
102const lyrics = atom({ plugin: 'now-playing', key: 'lyrics' } as const, null)
103/**
104 * Whether the band is hidden: the choice made this session, or null before
105 * one, when `readHidden` asks the store what the last session chose. The band
106 * starts hidden; /np show, the x control's opposite, or Claude's tool reveals it.
107 */
108const isHidden = atom({ plugin: 'now-playing', key: 'isHidden' } as const, null)
109const tick = atom({ plugin: 'now-playing', key: 'tick' } as const, 0)
110const artwork = atom({ plugin: 'now-playing', key: 'artwork' } as const, null)
111const queue = atom({ plugin: 'now-playing', key: 'queue' } as const, null)
112const queueCovers = atom({ plugin: 'now-playing', key: 'queueCovers' } as const, {})
113const search = atom({ plugin: 'now-playing', key: 'search' } as const, null)
114const modPlaylist = atom({ plugin: 'now-playing', key: 'modPlaylist' } as const, null)
115const mutedVolume = atom({ plugin: 'now-playing', key: 'mutedVolume' } as const, null)
116
117const SCRIPT_TIMEOUT_MS = 8000
118const SEARCH_TIMEOUT_MS = 15_000
119/** A batch of searches runs in one osascript, so the whole batch gets one longer bound. */
120const BATCH_SEARCH_TIMEOUT_MS = 90_000
121/** How long a player that refused or garbled a read is left alone. */
122const FAILURE_BACKOFF_MS = 30_000
123/** How long a player that was merely slow to answer is left alone. */
124const TIMEOUT_BACKOFF_MS = 5000
125const PAUSED_POLL_MS = 10_000
126const IDLE_POLL_MS = 5000
127const HIDDEN_POLL_MS = 15_000
128const TICK_MS = 1000
129const REFRESH_AFTER_CONTROL_MS = 400
130const MIN_POLL_SECONDS = 1
131const MAX_POLL_SECONDS = 30
132const DEFAULT_POLL_SECONDS = 2
133const DEFAULT_VOLUME_STEP = 10
134const MAX_VOLUME_STEP = 50
135const UNMUTE_FALLBACK_VOLUME = 50
136const MIN_TITLE_WIDTH = 8
137const DEFAULT_PLAYLIST_NAME = 'Claude Code'
138const MAX_TRACKS_PER_ADD = 50
139/** How many catalogue matches are weighed for a request the library lacks. */
140const CATALOGUE_FALLBACK_LIMIT = 5
141/** How many catalogue lookups run at once: Apple's public search throttles a burst. */
142const CATALOGUE_CONCURRENCY = 4
143
144/** A cover's size in cells at one site. */
145type CoverSize = { columns: number; rows: number }
146/** The small cover beside the compact band. */
147const COMPACT_COVER: CoverSize = { columns: 4, rows: 2 }
148/** The cover inside the large band: tall enough for the title, artist, bar and controls beside it. */
149const LARGE_COVER: CoverSize = { columns: 10, rows: 5 }
150/** A cover beside each row of the Up next pane, with the title and artist on two lines. */
151const QUEUE_COVER: CoverSize = { columns: 6, rows: 3 }
152/** The cells a row's `gap={1}` leaves between a cover and the text beside it. */
153const COVER_GAP_COLUMNS = 1
154/** The large band's frame: a border cell and a padding cell at each side. */
155const FRAME_COLUMNS = 4
156const FRAME_ROWS = 2
157/** The rows a large band takes: its cover and the frame; a terminal with fewer gets the compact band. */
158const LARGE_BAND_ROWS = LARGE_COVER.rows + FRAME_ROWS
159/** Narrower than this, the large band's rows would wrap, so the compact one is drawn. */
160const LARGE_BAND_MIN_COLUMNS = 60
161/** A progress bar shorter than this says nothing, so it is dropped. */
162const MIN_BAR_WIDTH = 8
163const BORDER_STYLE = 'round'
164/** The cover of the track on, as `.src` (downloaded or exported), `.png` and `.bmp` files, named per track. */
165const ARTWORK_FILE_STEM = 'claude-now-playing-cover'
166/** A track without a player ID is named by title, artist and album, cut to keep the file name sane. */
167const MAX_COVER_ID_LENGTH = 80
168const COVERS_DIRECTORY = 'claude-now-playing-covers'
169/** The side of the small square the cover is shrunk to for cell drawing: room for the largest cover's 10 by 10 pixels. */
170const THUMBNAIL_PIXELS = 24
171/** The dim rule drawn between the pane's search results and its queue. */
172
173/** The footer label never grows past this many cells, whatever the width. */
174const MAX_FOOTER_LABEL = 60
175/** Nor past this share of the terminal, so the engine's own labels keep room. */
176const FOOTER_LABEL_SHARE = 0.4
177const DEFAULT_FOOTER_WIDTH = 40
178
179/** How tall the Up next pane opens. */
180const QUEUE_PANE_ROWS = 16
181/** Cells the pane keeps clear at its right edge, so a row never touches the border. */
182const PANE_MARGIN_COLUMNS = 4
183const CLOSE_LABEL = 'close'
184/** The limit behind the whole add section, said where someone might expect to add a song they do not own. */
185const LIBRARY_ONLY_HINT = 'Only songs already in your Music library can be added; others open in Music, where you add them to your library first.'
186/** The header's room for the playlist name: the width less the close button and the gap before it. */
187const PANE_HEADER_RESERVED = plainButtonWidth(CLOSE_LABEL) + BUTTON_GAP
188const REMOVE_LABEL = 'remove'
189const PANE_REMOVE_RESERVED = plainButtonWidth(REMOVE_LABEL) + BUTTON_GAP
190
191const SOURCE_COLOURS: Readonly<Record<PlayerSource, string>> = {
192 spotify: '#1DB954',
193 music: '#FC3C44',
194}
195const PLAYING_GLYPH = '▶'
196const PAUSED_GLYPH = '❚❚'
197const NOTE_GLYPH = '♪'
198const SHUFFLE_GLYPH = '⇄'
199const REPEAT_GLYPH = '↻'
200
201type Placement = 'footer' | 'band' | 'both'
202/** The framed band with a big cover, or the two-row one. */
203type BandSize = 'large' | 'compact'
204
205type Settings = {
206 placement: Placement
207 size: BandSize
208 source: PlayerSource | 'auto'
209 pollMs: number
210 volumeStep: number
211 showArtwork: boolean
212 queueArtwork: boolean
213 quietWhileWorking: boolean
214 tellClaude: boolean
215 /** Whether searches also ask Apple's catalogue, for tracks the library lacks. */
216 catalogue: boolean
217 playlistName: string
218 preloadQueue: boolean
219 /** Whether a helper listens for the players' own change notifications, so the band updates at once. */
220 pushUpdates: boolean
221}
222
223/** What one load of the module carries between its hooks and timers. */
224type Session = {
225 settings: Settings
226 /** True while a refresh is in flight, so timers never stack osascript runs. */
227 isRefreshing: boolean
228 /** Players that failed: why, when each may be asked again, and how long it was held for. */
229 retryAfter: Map<PlayerSource, { until: number; failure: string; backoffMs: number }>
230 pollTimer: Timer | null
231 /** True on the terminal surface, where covers are drawn (as pictures or as cells). */
232 isTerminal: boolean
233 /** True when the terminal draws pictures (kitty graphics: kitty, Ghostty, WezTerm). */
234 canDrawArtwork: boolean
235 artworkDirectory: string
236 /** Covers already exported, by persistent ID; null for a track without one. */
237 coverCache: Map<string, Artwork | null>
238 /** Bumped per queue so a cover run for an old queue stops. */
239 coverRun: number
240 /** Bumped per track change, so the cover of a track no longer on never lands. */
241 artworkRun: number
242 /** True while the queue is being read, so track changes never stack reads. */
243 isReadingQueue: boolean
244 /** True while the player-event helper runs and reports changes, so polling can relax. */
245 hasListener: boolean
246 /** A queue owed once the current read ends, for the track on then (null: the player stopped); null with none owed. */
247 pendingQueue: { current: NowPlaying | null } | null
248 /** The Apple Music storefront catalogue searches use (GB, US), from the Mac's locale. */
249 storefront: string
250 /** `$.clock.now()` when this load of the module started, so `/np version` can say how fresh it is. */
251 loadedAt: number
252}
253
254function readSettings(options: PluginOptions): Settings {
255 const placement = options.placement === 'footer' || options.placement === 'both' ? options.placement : 'band'
256 const size: BandSize = options.size === 'compact' ? 'compact' : 'large'
257 const source = options.source === 'music' || options.source === 'spotify' ? options.source : 'auto'
258 const pollSeconds = clamp(Number(options.pollSeconds) || DEFAULT_POLL_SECONDS, MIN_POLL_SECONDS, MAX_POLL_SECONDS)
259 const volumeStep = clamp(Number(options.volumeStep) || DEFAULT_VOLUME_STEP, 1, MAX_VOLUME_STEP)
260 const playlistName = typeof options.playlistName === 'string' && options.playlistName.trim() !== '' ? options.playlistName.trim() : DEFAULT_PLAYLIST_NAME
261 return {
262 placement,
263 size,
264 source,
265 pollMs: pollSeconds * MS_PER_SECOND,
266 volumeStep,
267 showArtwork: options.artwork !== false,
268 queueArtwork: options.queueArtwork !== false,
269 quietWhileWorking: options.quietWhileWorking !== false,
270 tellClaude: options.tellClaude === true,
271 catalogue: options.catalogue !== false,
272 playlistName,
273 preloadQueue: options.preloadQueue !== false,
274 pushUpdates: options.pushUpdates !== false,
275 }
276}
277
278function clamp(value: number, low: number, high: number): number {
279 return Math.min(high, Math.max(low, value))
280}
281
282function errorText(error: unknown): string {
283 return error instanceof Error ? error.message : String(error)
284}
285
286/** The fields a redraw cares about, as one string: equal keys mean nothing visible changed. */
287function statusKey({ current, failure }: PlayerStatus): string {
288 if (current === null) return JSON.stringify({ failure })
289 const { source, state, trackId, title, artist, album, volume, shuffle, repeat } = current
290 return JSON.stringify({
291 failure,
292 source,
293 state,
294 trackId,
295 title,
296 artist,
297 album,
298 volume,
299 shuffle,
300 repeat,
301 position: Math.floor(current.positionSeconds),
302 duration: Math.floor(current.durationSeconds),
303 })
304}
305
306function isSameTrack(a: NowPlaying | null, b: NowPlaying | null): boolean {
307 if (a === null || b === null) return a === b
308 return a.source === b.source && a.trackId === b.trackId
309}
310
311/** The one dim label the footer shows: glyphs, track, and the clock when it fits. */
312function footerLabel(current: NowPlaying, nowMs: number, columns: number | undefined): string {
313 const width = columns === undefined ? DEFAULT_FOOTER_WIDTH : Math.min(MAX_FOOTER_LABEL, Math.floor(columns * FOOTER_LABEL_SHARE))
314 const stateGlyph = current.state === 'playing' ? PLAYING_GLYPH : PAUSED_GLYPH
315 const clock = ` ${formatClock(positionNow(current, nowMs))}/${formatClock(current.durationSeconds)}`
316 const lead = `${NOTE_GLYPH} ${stateGlyph} `
317 const track = trackLine(current)
318
319 const roomWithClock = width - lead.length - clock.length
320 if (roomWithClock >= MIN_TITLE_WIDTH) {
321 return lead + truncateText(track, roomWithClock) + clock
322 }
323 return lead + truncateText(track, Math.max(MIN_TITLE_WIDTH, width - lead.length))
324}
325
326/** `⇄` while shuffling, `↻` or `↻1` while repeating; empty with neither. */
327function modeGlyphs(current: NowPlaying): string {
328 const parts: string[] = []
329 if (current.shuffle) parts.push(SHUFFLE_GLYPH)
330 if (current.repeat === 'all') parts.push(REPEAT_GLYPH)
331 if (current.repeat === 'one') parts.push(`${REPEAT_GLYPH}1`)
332 return parts.join(' ')
333}
334
335function describeTrack(current: NowPlaying, nowMs: number): string {
336 return (
337 `${displayName(current.source)} ${current.state}: ${trackLine(current)}` +
338 ` (${formatClock(positionNow(current, nowMs))} / ${formatClock(current.durationSeconds)}, volume ${current.volume}%)`
339 )
340}
341
342/** The text a copy puts on the clipboard: the share link, or the track's name. */
343function shareText(current: NowPlaying): string {
344 return current.shareUrl ?? trackLine(current)
345}
346
347/** `Title — Artist`, the line every list, toast and label names a track by. */
348function trackLine(track: { title: string; artist: string }): string {
349 return `${track.title} — ${track.artist}`
350}
351
352/** `Title — Artist · Album`, or just `Title — Artist` for a track without an album. */
353function trackLineWithAlbum(track: { title: string; artist: string; album: string }): string {
354 return track.album ? `${trackLine(track)} · ${track.album}` : trackLine(track)
355}
356
357/** A player that could not be read, and the one line saying why. */
358type ReadFailure = { failure: string }
359
360function isReadFailure(reading: NowPlaying | null | ReadFailure): reading is ReadFailure {
361 return reading !== null && 'failure' in reading
362}
363
364/** Runs an AppleScript through osascript, bounded by `timeoutMs`. */
365async function runScript($: EngineInterface, script: string, timeoutMs: number = SCRIPT_TIMEOUT_MS) {
366 return $.process.run(osascriptArgv(script), { timeoutMs })
367}
368
369/** Runs a script and reads its reply with `parse`; null when the script failed. */
370async function readScript<T>(
371 $: EngineInterface,
372 script: string,
373 parse: (stdout: string) => T | null,
374 timeoutMs: number = SCRIPT_TIMEOUT_MS,
375): Promise<T | null> {
376 const ran = await runScript($, script, timeoutMs)
377 return ran.exitCode === 0 ? parse(ran.stdout) : null
378}
379
380async function readPlayer(
381 $: EngineInterface,
382 session: Session,
383 source: PlayerSource,
384 now: number,
385): Promise<NowPlaying | null | ReadFailure> {
386 const held = session.retryAfter.get(source)
387 if (held !== undefined && held.until > now) return { failure: held.failure }
388 try {
389 const ran = await runScript($, statusScript(source))
390 if (ran.exitCode !== 0) {
391 return holdPlayer($, session, source, now, ran.stderr)
392 }
393 session.retryAfter.delete(source)
394 return parseStatus(source, ran.stdout, now)
395 } catch (error) {
396 return holdPlayer($, session, source, now, errorText(error))
397 }
398}
399
400/** Remembers a failed player so it is not asked again for a while: briefly when it was only slow. */
401function holdPlayer($: EngineInterface, session: Session, source: PlayerSource, now: number, reason: string): ReadFailure {
402 const failure = describeFailure(source, reason)
403 const backoffMs = classifyFailure(reason) === 'timeout' ? TIMEOUT_BACKOFF_MS : FAILURE_BACKOFF_MS
404 session.retryAfter.set(source, { until: now + backoffMs, failure, backoffMs })
405 $.ui.log(`${displayName(source)} status failed: ${reason.trim()}`)
406 return { failure }
407}
408
409/** The players open right now, as pgrep lists them. */
410async function listRunningPlayers($: EngineInterface): Promise<PlayerSource[]> {
411 const ran = await $.process.run(runningPlayersArgv(), { timeoutMs: SCRIPT_TIMEOUT_MS })
412 return parseRunningPlayers(ran.stdout)
413}
414
415/** The running players the band follows, per the `source` setting. */
416async function runningPlayers($: EngineInterface, session: Session): Promise<PlayerSource[]> {
417 const running = await listRunningPlayers($)
418 const wanted = session.settings.source
419 return wanted === 'auto' ? running : running.filter(source => source === wanted)
420}
421
422async function isMusicRunning($: EngineInterface): Promise<boolean> {
423 return (await listRunningPlayers($)).includes('music')
424}
425
426/** Asks the running players what is on and writes the band's status once it changed. */
427async function refresh($: EngineInterface, session: Session): Promise<PlayerStatus> {
428 const previous = await read($, status)
429 if (session.isRefreshing) return previous
430 session.isRefreshing = true
431 try {
432 const now = await $.clock.now()
433 const sources = await runningPlayers($, session)
434 const readings = await Promise.all(sources.map(source => readPlayer($, session, source, now)))
435
436 const found = readings.filter((reading): reading is NowPlaying | null => !isReadFailure(reading))
437 const firstFailure = readings.find(isReadFailure)
438 const current = pickCurrent(found, previous.current?.source ?? null)
439 const failure = current === null && firstFailure !== undefined ? firstFailure.failure : null
440 const next: PlayerStatus = { current, failure }
441
442 if (statusKey(previous) !== statusKey(next)) {
443 await update($, status, () => next)
444 }
445 if (!isSameTrack(previous.current, current)) {
446 void refreshArtwork($, session, current)
447 void preloadQueue($, session, current)
448 void refreshLyricsIfOpen($, session, current)
449 if (current !== null) void recordListen($, current)
450 }
451 return next
452 } catch (error) {
453 $.ui.log(`refresh failed: ${errorText(error)}`)
454 return previous
455 } finally {
456 session.isRefreshing = false
457 }
458}
459
460/** How long to wait before the next poll: quick while playing, lazy otherwise. */
461function nextPollDelay(session: Session, result: PlayerStatus, hidden: boolean): number {
462 if (hidden) return HIDDEN_POLL_MS
463 if (session.hasListener) {
464 if (result.current?.state === 'playing') return Math.max(session.settings.pollMs, LISTENER_PLAYING_POLL_MS)
465 if (result.current?.state === 'paused') return LISTENER_PAUSED_POLL_MS
466 }
467 if (result.current?.state === 'playing') return session.settings.pollMs
468 if (result.current?.state === 'paused') return Math.max(session.settings.pollMs, PAUSED_POLL_MS)
469 if (result.failure !== null) return shortestHold(session)
470 return IDLE_POLL_MS
471}
472
473/** How soon a held player is worth asking again: the shortest hold in force. */
474function shortestHold(session: Session): number {
475 const holds = [...session.retryAfter.values()].map(held => held.backoffMs)
476 return holds.length === 0 ? FAILURE_BACKOFF_MS : Math.min(...holds)
477}
478
479function schedulePoll($: EngineInterface, session: Session, delayMs: number): void {
480 session.pollTimer?.cancel()
481 session.pollTimer = $.clock.after(delayMs, () => void pollAndReschedule($, session))
482}
483
484/** Reads the players again shortly, once a command they were sent has taken effect. */
485function refreshSoon($: EngineInterface, session: Session): void {
486 $.clock.after(REFRESH_AFTER_CONTROL_MS, () => void refresh($, session))
487}
488
489async function pollAndReschedule($: EngineInterface, session: Session): Promise<void> {
490 const hidden = await readHidden($)
491 const result = hidden ? await read($, status) : await refresh($, session)
492 schedulePoll($, session, nextPollDelay(session, result, hidden))
493}
494
495/** Once a second while a track plays and shows, so its clock and bar move between polls. */
496async function tickProgress($: EngineInterface): Promise<void> {
497 const [hidden, playerStatus] = await Promise.all([readHidden($), read($, status)])
498 if (hidden || playerStatus.current?.state !== 'playing') return
499 await update($, tick, count => count + 1)
500}
501
502/** Where a cover's fetched bytes, its PNG and its thumbnail BMP live. */
503type CoverPaths = { source: string; png: string; bmp: string }
504
505function coverPaths(directory: string, stem: string): CoverPaths {
506 return { source: `${directory}/${stem}.src`, png: `${directory}/${stem}.png`, bmp: `${directory}/${stem}.bmp` }
507}
508
509/** Shrinks the PNG to a few pixels, for terminals without pictures; each site folds them into its own cells. */
510async function readCoverThumbnail($: EngineInterface, paths: CoverPaths): Promise<Thumbnail | null> {
511 try {
512 const shrunk = await $.process.run(toThumbnailArgv(paths.png, paths.bmp, THUMBNAIL_PIXELS), { timeoutMs: SCRIPT_TIMEOUT_MS })
513 if (shrunk.exitCode !== 0) return null
514 const { base64 } = await $.fs.read(paths.bmp, { as: 'bytes' })
515 return decodeBmp(bytesFromBase64(base64))
516 } catch (error) {
517 $.ui.log(`thumbnail failed: ${errorText(error)}`)
518 return null
519 }
520}
521
522/** The `Artwork` for the PNG at `paths`, with a small picture where the terminal cannot draw the PNG. */
523async function artworkFrom($: EngineInterface, session: Session, trackId: string, paths: CoverPaths): Promise<Artwork> {
524 const thumbnail = session.canDrawArtwork ? null : await readCoverThumbnail($, paths)
525 const generation = Math.floor(await $.clock.now())
526 return { trackId, path: paths.png, generation, thumbnail }
527}
528
529/**
530 * Fetches the cover of the track on into a PNG and a thumbnail, or clears it.
531 * Two quick track changes start two fetches; only the latest may publish.
532 */
533async function refreshArtwork($: EngineInterface, session: Session, current: NowPlaying | null): Promise<void> {
534 const run = ++session.artworkRun
535 const cover = await fetchCurrentCover($, session, current)
536 if (run !== session.artworkRun) return
537 await update($, artwork, () => cover)
538}
539
540/** A file name for a track's cover: the stem and its ID, with anything a path cannot hold replaced. */
541function coverFileStem(trackId: string): string {
542 const safeId = trackId.replace(/[^A-Za-z0-9]+/g, '-').slice(0, MAX_COVER_ID_LENGTH)
543 return `${ARTWORK_FILE_STEM}-${safeId}`
544}
545
546/**
547 * Makes sure the PNG at `paths` exists: one already there, from earlier in the
548 * session or before a reload, is kept; otherwise `fetchSource` writes the
549 * `.src` file and says whether it did, and sips converts it.
550 */
551async function ensureCoverPng($: EngineInterface, paths: CoverPaths, fetchSource: () => Promise<boolean>): Promise<boolean> {
552 if (await $.fs.exists(paths.png)) return true
553 if (!(await fetchSource())) return false
554 const converted = await $.process.run(toPngArgv(paths.source, paths.png), { timeoutMs: SCRIPT_TIMEOUT_MS })
555 return converted.exitCode === 0
556}
557
558/** Music exports the current track's cover to `path`; true when it wrote one. */
559async function exportCurrentCover($: EngineInterface, path: string): Promise<boolean> {
560 const exported = await runScript($, artworkExportScript(path))
561 return exported.exitCode === 0 && exported.stdout.trim() !== ''
562}
563
564/** Downloads the cover at `url` to `path`; true when curl did. */
565async function downloadCover($: EngineInterface, url: string, path: string): Promise<boolean> {
566 const downloaded = await $.process.run(downloadArgv(url, path), { timeoutMs: SCRIPT_TIMEOUT_MS })
567 return downloaded.exitCode === 0
568}
569
570/** The cover of the track on: Music exports it, Spotify names a URL to download; null without one. */
571async function fetchCurrentCover($: EngineInterface, session: Session, current: NowPlaying | null): Promise<Artwork | null> {
572 const wantsArtwork = session.settings.showArtwork && session.isTerminal && session.settings.placement !== 'footer'
573 if (current === null || !wantsArtwork) return null
574 const paths = coverPaths(session.artworkDirectory, coverFileStem(current.trackId))
575 const { artworkUrl } = current
576 const fetchSource =
577 current.source === 'music'
578 ? () => exportCurrentCover($, paths.source)
579 : artworkUrl === null
580 ? () => Promise.resolve(false)
581 : () => downloadCover($, artworkUrl, paths.source)
582 try {
583 if (!(await ensureCoverPng($, paths, fetchSource))) return null
584 return await artworkFrom($, session, current.trackId, paths)
585 } catch (error) {
586 $.ui.log(`artwork failed: ${errorText(error)}`)
587 return null
588 }
589}
590
591/** Reads Music's current playlist around the track on, for the queue pane. */
592async function refreshQueue($: EngineInterface, session: Session, current: NowPlaying | null): Promise<Queue | null> {
593 if (current === null) {
594 await update($, queue, () => null)
595 return null
596 }
597 if (current.source !== 'music') {
598 const empty = emptyQueue(session, current, "Spotify's scripting has no queue, so Up next shows nothing. Controls still work; Up next, search and the playlist need Apple Music.")
599 await update($, queue, () => empty)
600 return empty
601 }
602 try {
603 const next = await readMusicQueue($, session, current)
604 await update($, queue, () => next)
605 void fetchQueueCovers($, session, next.entries)
606 return next
607 } catch (error) {
608 $.ui.log(`queue failed: ${errorText(error)}`)
609 return null
610 }
611}
612
613/** A queue with nothing to list and a line saying why. */
614function emptyQueue(session: Session, current: NowPlaying, note: string, playlistName = ''): Queue {
615 return {
616 source: current.source,
617 trackId: current.trackId,
618 currentIndex: 0,
619 playlistName,
620 isModPlaylist: playlistName === session.settings.playlistName,
621 note,
622 entries: [],
623 }
624}
625
626async function readMusicQueue($: EngineInterface, session: Session, current: NowPlaying): Promise<Queue> {
627 const parsed = await readScript($, queueScript(), parseQueue)
628 if (parsed === null) return emptyQueue(session, current, 'Music gave no playlist for this track.')
629 if (parsed.entries.length === 0) return emptyQueue(session, current, 'Music gave no tracks around this one.', parsed.playlistName)
630 // Music's index names the row on, which a song twice in one playlist needs;
631 // the ID is the fallback when the index names no listed row.
632 const byIndex = parsed.entries.find(entry => entry.index === parsed.currentIndex)
633 const byId = parsed.entries.find(entry => entry.id === current.trackId)
634 const currentEntry = byIndex ?? byId
635 return {
636 source: 'music',
637 trackId: currentEntry?.id ?? current.trackId,
638 currentIndex: currentEntry?.index ?? parsed.currentIndex,
639 playlistName: parsed.playlistName,
640 isModPlaylist: parsed.playlistName === session.settings.playlistName,
641 note: null,
642 entries: parsed.entries,
643 }
644}
645
646/** Exports each queue track's cover in turn, publishing them as they land. */
647async function fetchQueueCovers($: EngineInterface, session: Session, entries: readonly QueueEntry[]): Promise<void> {
648 if (!session.isTerminal || !session.settings.queueArtwork) return
649 const run = ++session.coverRun
650 const directory = `${session.artworkDirectory}/${COVERS_DIRECTORY}`
651 try {
652 await $.process.run(['mkdir', '-p', directory], { timeoutMs: SCRIPT_TIMEOUT_MS })
653 for (const entry of entries) {
654 if (run !== session.coverRun) return
655 if (entry.id === '') continue
656 let cover = session.coverCache.get(entry.id)
657 if (cover === undefined) {
658 cover = await exportCoverById($, session, entry.id, directory)
659 session.coverCache.set(entry.id, cover)
660 }
661 if (cover !== null) {
662 const found = cover
663 await update($, queueCovers, covers => (covers[entry.id] === undefined ? { ...covers, [entry.id]: found } : covers))
664 }
665 }
666 } catch (error) {
667 $.ui.log(`queue covers failed: ${errorText(error)}`)
668 }
669}
670
671/** The cover of the library track with this persistent ID, exported by Music; null without one. */
672async function exportCoverById($: EngineInterface, session: Session, id: string, directory: string): Promise<Artwork | null> {
673 const paths = coverPaths(directory, id)
674 const fetchSource = async () => {
675 const exported = await runScript($, artworkByIdScript(id, paths.source))
676 return exported.exitCode === 0 && exported.stdout.trim() === 'ok'
677 }
678 if (!(await ensureCoverPng($, paths, fetchSource))) return null
679 return artworkFrom($, session, id, paths)
680}
681
682async function isQueueOpen($: EngineInterface): Promise<boolean> {
683 const panes = await $.ui.panes()
684 return panes.some(pane => pane.id === QUEUE_PANE)
685}
686
687async function refreshQueueIfOpen($: EngineInterface, session: Session, current: NowPlaying | null): Promise<void> {
688 try {
689 if (await isQueueOpen($)) await refreshQueue($, session, current)
690 } catch (error) {
691 $.ui.log(`queue check failed: ${errorText(error)}`)
692 }
693}
694
695/**
696 * Keeps the queue and its covers warm as tracks change, so the pane opens
697 * with everything already fetched. One read runs at a time; a track that
698 * changed meanwhile is read once the current read ends.
699 */
700async function preloadQueue($: EngineInterface, session: Session, current: NowPlaying | null): Promise<void> {
701 if (!session.settings.preloadQueue) {
702 await refreshQueueIfOpen($, session, current)
703 return
704 }
705 if (session.isReadingQueue) {
706 session.pendingQueue = { current }
707 return
708 }
709 session.isReadingQueue = true
710 try {
711 let wanted = current
712 while (true) {
713 await refreshQueue($, session, wanted)
714 const pending = session.pendingQueue
715 session.pendingQueue = null
716 if (pending === null || isSameTrack(pending.current, wanted)) break
717 wanted = pending.current
718 }
719 } finally {
720 session.isReadingQueue = false
721 }
722}
723
724/** True when the queue in state already describes the track on. */
725function isQueueFresh(list: Queue | null, current: NowPlaying | null): boolean {
726 return list !== null && current !== null && list.source === current.source && list.trackId === current.trackId
727}
728
729/** Opens the queue pane, or closes it when it is already open. */
730async function toggleQueue($: EngineInterface, session: Session, current: NowPlaying | null): Promise<string> {
731 if (await isQueueOpen($)) {
732 await closeQueue($)
733 return 'Up next & playlist closed.'
734 }
735 return openQueue($, session, current)
736}
737
738async function openQueue($: EngineInterface, session: Session, current: NowPlaying | null): Promise<string> {
739 // Preloaded data opens at once; anything stale is fetched first, the playlist count in the background.
740 if (!isQueueFresh(await read($, queue), current)) {
741 await refreshQueue($, session, current)
742 }
743 void refreshModPlaylist($, session)
744 const opened = await $.ui.open({ id: QUEUE_PANE, title: 'Up next & playlist', rows: QUEUE_PANE_ROWS })
745 if (!opened.isPlaced) {
746 return 'Up next: widen the terminal to see the queue pane.'
747 }
748 return 'Up next & playlist opened. /np queue, the q control or ctrl+x x closes it.'
749}
750
751async function closeQueue($: EngineInterface): Promise<void> {
752 try {
753 await $.ui.close({ id: QUEUE_PANE })
754 } catch (error) {
755 $.ui.log(`queue close refused: ${errorText(error)}`)
756 }
757}
758
759/** Makes sure the mod's playlist exists and records how many tracks it holds; null with Music off. */
760async function refreshModPlaylist($: EngineInterface, session: Session): Promise<ModPlaylist | null> {
761 try {
762 const parsed = await readScript($, ensurePlaylistScript(session.settings.playlistName), parsePlaylist, SEARCH_TIMEOUT_MS)
763 if (parsed === null) return null
764 const playlist: ModPlaylist = { name: session.settings.playlistName, count: parsed.count, entries: parsed.entries }
765 await update($, modPlaylist, () => playlist)
766 return playlist
767 } catch (error) {
768 $.ui.log(`playlist check failed: ${errorText(error)}`)
769 return null
770 }
771}
772
773type LibraryMatches = { results: SearchResult[]; note: string | null }
774
775/** Music's own ranked search of the library; the caller has seen that Music is running. */
776async function runLibrarySearch($: EngineInterface, query: string, limit: number): Promise<LibraryMatches> {
777 try {
778 const ran = await runScript($, searchLibraryScript(query, limit), SEARCH_TIMEOUT_MS)
779 if (ran.exitCode !== 0) return { results: [], note: describeFailure('music', ran.stderr) }
780 return { results: parseSearch(ran.stdout), note: null }
781 } catch (error) {
782 return { results: [], note: `Music could not search: ${errorText(error)}` }
783 }
784}
785
786/** Searches the Music library alone, saying so when Music is not running. */
787async function searchLibraryOnly($: EngineInterface, query: string, limit: number): Promise<LibraryMatches> {
788 if (!(await isMusicRunning($))) return { results: [], note: 'Music is not running, so the library cannot be searched.' }
789 return runLibrarySearch($, query, limit)
790}
791
792/** Searches the Apple Music catalogue through Apple's public search API. */
793async function searchCatalogue($: EngineInterface, session: Session, query: string, limit: number): Promise<SearchResult[]> {
794 if (!session.settings.catalogue) return []
795 try {
796 const response = await $.http.fetch(catalogueSearchUrl(query, session.storefront, limit))
797 if (!response.ok) {
798 $.ui.log(`catalogue search answered ${response.status}`)
799 return []
800 }
801 return parseCatalogue(response.text)
802 } catch (error) {
803 $.ui.log(`catalogue search failed: ${errorText(error)}`)
804 return []
805 }
806}
807
808type SearchOptions = { limit?: number; withLibrary?: boolean; withCatalogue?: boolean }
809
810/** The line for a search that found nothing, naming where it looked. */
811function nothingFoundNote(query: string, inLibrary: boolean, inCatalogue: boolean): string {
812 if (inLibrary && inCatalogue) return `Nothing matches "${query}" in the library or on Apple Music.`
813 if (inCatalogue) return `Nothing on Apple Music matches "${query}".`
814 return `Nothing in the library matches "${query}".`
815}
816
817/**
818 * Searches the library and the catalogue, either unless told not to, and
819 * keeps the answer for the pane: library tracks to add, and catalogue tracks
820 * the library lacks. A library that could not be searched says so in the
821 * note whatever the catalogue found, so an empty library section is explained.
822 */
823async function searchMusic(
824 $: EngineInterface,
825 session: Session,
826 query: string,
827 { limit = MAX_SEARCH_RESULTS, withLibrary = true, withCatalogue = true }: SearchOptions = {},
828): Promise<LibrarySearch> {
829 const trimmed = query.trim()
830 const finish = async (found: LibrarySearch): Promise<LibrarySearch> => {
831 await update($, search, () => found)
832 return found
833 }
834 if (trimmed === '') return finish({ query: trimmed, results: [], catalogue: [], note: null })
835
836 const asksCatalogue = withCatalogue && session.settings.catalogue
837 const [library, catalogue] = await Promise.all([
838 withLibrary ? searchLibraryOnly($, trimmed, limit) : Promise.resolve<LibraryMatches>({ results: [], note: null }),
839 asksCatalogue ? searchCatalogue($, session, trimmed, limit) : Promise.resolve([]),
840 ])
841 const unowned = withoutLibraryDuplicates(catalogue, library.results)
842 const isEmpty = library.results.length === 0 && unowned.length === 0
843 const note = library.note ?? (isEmpty ? nothingFoundNote(trimmed, withLibrary, asksCatalogue) : null)
844 return finish({ query: trimmed, results: library.results, catalogue: unowned, note })
845}
846
847/** Opens a catalogue track's page in the Music app, where it can be played or added to the library. */
848async function openInMusic($: EngineInterface, url: string): Promise<string> {
849 if (!/^(music|https?):\/\/music\.apple\.com\//.test(url)) return `Not an Apple Music link: ${url}`
850 try {
851 await $.process.run(['open', musicAppUrl(url)], { timeoutMs: SCRIPT_TIMEOUT_MS })
852 return 'Opened in Music. Press play there, or add it to your library and it becomes addable here.'
853 } catch (error) {
854 return `Could not open Music: ${errorText(error)}`
855 }
856}
857
858/** Adds library tracks by ID to the mod's playlist, skipping ones already in it. */
859async function addTracks($: EngineInterface, session: Session, ids: readonly string[]): Promise<AddOutcome | null> {
860 const unique = [...new Set(ids.filter(id => id !== ''))].slice(0, MAX_TRACKS_PER_ADD)
861 if (unique.length === 0) return { added: 0, skipped: 0, count: 0 }
862 try {
863 const outcome = await readScript($, addTracksScript(session.settings.playlistName, unique), parseAdded, SEARCH_TIMEOUT_MS)
864 if (outcome !== null) {
865 // The pane lists the playlist, so it learns of the new tracks at once.
866 await refreshModPlaylist($, session)
867 }
868 return outcome
869 } catch (error) {
870 $.ui.log(`add failed: ${errorText(error)}`)
871 return null
872 }
873}
874
875/** Starts the mod's playlist from one of its tracks, pressed in the pane. */
876async function playPlaylistTrack($: EngineInterface, session: Session, index: number): Promise<void> {
877 try {
878 await runScript($, playPlaylistTrackScript(session.settings.playlistName, index))
879 } catch (error) {
880 $.ui.toast(`Music did not start the track: ${errorText(error)}`)
881 }
882 refreshSoon($, session)
883}
884
885/** Whether the mod's playlist was started, and why not when it was not. */
886type PlaylistStart = { started: true } | { started: false; failure: string | null }
887
888/** Tells Music to play the mod's playlist from its first track; a refusal or error is the failure. */
889async function startPlaylist($: EngineInterface, session: Session): Promise<string | null> {
890 try {
891 const ran = await runScript($, playPlaylistScript(session.settings.playlistName))
892 if (ran.exitCode !== 0) return describeFailure('music', ran.stderr)
893 return null
894 } catch (error) {
895 return `Music did not start the playlist: ${errorText(error)}`
896 } finally {
897 refreshSoon($, session)
898 }
899}
900
901/** Starts the mod's playlist when nothing is playing, so added tracks are heard. */
902async function startPlaylistIfIdle($: EngineInterface, session: Session): Promise<PlaylistStart> {
903 const { current } = await refresh($, session)
904 if (current !== null && current.state === 'playing') return { started: false, failure: null }
905 const failure = await startPlaylist($, session)
906 return failure === null ? { started: true } : { started: false, failure }
907}
908
909async function playModPlaylist($: EngineInterface, session: Session): Promise<string> {
910 if (!(await isMusicRunning($))) return 'Music is not running.'
911 const playlist = await refreshModPlaylist($, session)
912 if (playlist === null) return `Could not read the "${session.settings.playlistName}" playlist.`
913 if (playlist.count === 0) return `The "${playlist.name}" playlist is empty. Search in the Up next pane, or ask Claude to add songs.`
914 const failure = await startPlaylist($, session)
915 if (failure !== null) return failure
916 return `Playing "${playlist.name}" (${playlist.count} tracks).`
917}
918
919/** Empties the mod's playlist and tells the pane, which lists it. */
920async function clearModPlaylist($: EngineInterface, session: Session): Promise<string> {
921 if (!(await isMusicRunning($))) return 'Music is not running.'
922 const name = session.settings.playlistName
923 let removed: number | null
924 try {
925 removed = await readScript($, clearPlaylistScript(name), parseCleared, SEARCH_TIMEOUT_MS)
926 } catch (error) {
927 return `Music could not clear "${name}": ${errorText(error)}`
928 }
929 if (removed === null) return `Could not clear the "${name}" playlist.`
930 // Music keeps the track on but lists the library as its playlist from here,
931 // and the band's refresh sees the same track, so the queue is read on purpose.
932 const { current } = await refresh($, session)
933 await Promise.all([refreshModPlaylist($, session), refreshQueue($, session, current)])
934 if (removed === 0) return `"${name}" was already empty.`
935 return `Removed ${removed} ${removed === 1 ? 'track' : 'tracks'} from "${name}". The tracks stay in your library.`
936}
937
938/** The mod's playlist as the pane, toasts and replies name it: "Claude Code playlist", unless its name says so already. */
939function playlistLabel(session: Session): string {
940 const name = session.settings.playlistName
941 return /playlist$/i.test(name) ? name : `${name} playlist`
942}
943
944/** True while Music plays the mod's playlist, so a track added to it is also up next. */
945async function isModPlaylistPlaying($: EngineInterface): Promise<boolean> {
946 const list = await read($, queue)
947 return list !== null && list.note === null && list.isModPlaylist
948}
949
950/** Adds one found track from the pane, and tells the person in a toast where it went. */
951async function addResult($: EngineInterface, session: Session, result: SearchResult): Promise<void> {
952 const label = playlistLabel(session)
953 const outcome = await addTracks($, session, [result.id])
954 if (outcome === null) {
955 $.ui.toast(`Could not add ${result.title} to the ${label}.`)
956 return
957 }
958 if (outcome.added === 0) {
959 $.ui.toast(`Already in the ${label}: ${trackLine(result)}`)
960 return
961 }
962 const start = await startPlaylistIfIdle($, session)
963 const where = start.started ? ' · playing it now' : (await isModPlaylistPlaying($)) ? ' · up next' : ''
964 $.ui.toast(`Added to the ${label}: ${trackLine(result)}${where}`)
965 if (!start.started && start.failure !== null) $.ui.toast(start.failure)
966 // A start is followed by a refresh that reads the queue anew; otherwise the pane is told now.
967 if (!start.started) {
968 const { current } = await read($, status)
969 await refreshQueue($, session, current)
970 }
971}
972
973/** Each request's best library match, or the catalogue's nearest offer for one the library lacks. */
974type FoundRequests = {
975 matched: { request: string; result: SearchResult }[]
976 missing: { request: string; onAppleMusic: SearchResult | null }[]
977}
978
979/** Runs `task` over `items` a few at a time, keeping the results in order. */
980async function inBatches<T, R>(items: readonly T[], size: number, task: (item: T) => Promise<R>): Promise<R[]> {
981 const results: R[] = []
982 for (let start = 0; start < items.length; start += size) {
983 results.push(...(await Promise.all(items.slice(start, start + size).map(task))))
984 }
985 return results
986}
987
988/**
989 * Looks every request up in one library script, then asks the catalogue
990 * about the misses a few at a time, so a batch of fifty costs one osascript
991 * launch and a short run of fetches rather than a hundred turns. A library
992 * that could not be searched is a failure, not fifty tracks nobody owns.
993 */
994async function findRequests($: EngineInterface, session: Session, requests: readonly string[]): Promise<FoundRequests | { failure: string }> {
995 const parts = requests.map(request => ({ request, ...splitTrackQuery(request) }))
996 // Music's search ranks across fields, so the artist helps it find the track.
997 const queries = parts.map(({ title, artist }) => (artist === null ? title : `${title} ${artist}`))
998 const ran = await runScript($, searchLibraryManyScript(queries), BATCH_SEARCH_TIMEOUT_MS)
999 if (ran.exitCode !== 0) return { failure: describeFailure('music', ran.stderr) }
1000 const results = parseSearchMany(ran.stdout, queries.length)
1001
1002 const matched: FoundRequests['matched'] = []
1003 const unmatched: typeof parts = []
1004 parts.forEach((part, index) => {
1005 const best = pickBestMatch(results[index] ?? [], part.title, part.artist)
1006 if (best === null) unmatched.push(part)
1007 else matched.push({ request: part.request, result: best })
1008 })
1009
1010 const missing = await inBatches(unmatched, CATALOGUE_CONCURRENCY, async ({ request, title, artist }) => {
1011 const offers = await searchCatalogue($, session, request, CATALOGUE_FALLBACK_LIMIT)
1012 return { request, onAppleMusic: pickBestMatch(offers, title, artist) }
1013 })
1014 return { matched, missing }
1015}
1016
1017/**
1018 * What Claude's add_tracks tool does: each request (`Title — Artist` or just
1019 * a title) is searched in the library and its best match added; the summary
1020 * names what landed and what the library lacks.
1021 */
1022async function addByQueries($: EngineInterface, session: Session, queries: readonly string[], play: boolean): Promise<string> {
1023 const requests = queries.map(query => query.trim()).filter(query => query !== '').slice(0, MAX_TRACKS_PER_ADD)
1024 if (requests.length === 0) return 'No tracks were named.'
1025 if (!(await isMusicRunning($))) return 'Music is not running, so nothing could be added. Open Music and try again.'
1026
1027 const found = await findRequests($, session, requests)
1028 if ('failure' in found) return `The library could not be searched, so nothing was added. ${found.failure}`
1029 const { matched, missing } = found
1030
1031 // With no library match there is nothing to add, and no count to misreport.
1032 const outcome = matched.length === 0 ? null : await addTracks($, session, matched.map(match => match.result.id))
1033 const label = playlistLabel(session)
1034 const lines: string[] = []
1035 if (matched.length === 0) {
1036 lines.push(`None of the ${requests.length === 1 ? 'request' : 'requests'} matched a library track, so the ${label} is unchanged.`)
1037 } else if (outcome === null) {
1038 lines.push(`Music refused the additions to the ${label}.`)
1039 } else {
1040 lines.push(`Added ${outcome.added} track${outcome.added === 1 ? '' : 's'} to the ${label} (${outcome.skipped} already there; ${outcome.count} in it now).`)
1041 for (const match of matched) lines.push(` + ${trackLineWithAlbum(match.result)}`)
1042 const start: PlaylistStart = play && outcome.added > 0 ? await startPlaylistIfIdle($, session) : { started: false, failure: null }
1043 if (start.started) lines.push(`Started playing the ${label}.`)
1044 else if (start.failure !== null) lines.push(`The playlist did not start: ${start.failure}`)
1045 else if (await isModPlaylistPlaying($)) lines.push(`The ${label} is what's playing, so they are up next.`)
1046 else lines.push(`They are in the playlist, not in Up next: /np playlist (or the pane's "play it") plays it.`)
1047 }
1048 if (missing.length > 0) {
1049 lines.push(`Not in the library (${missing.length}); only library tracks can go in a playlist. On Apple Music, each can be opened in Music with open_in_music or /np open <link>, and added to the library there, after which add_tracks or /np add can add it:`)
1050 for (const miss of missing) {
1051 lines.push(
1052 miss.onAppleMusic === null
1053 ? ` - ${miss.request}: not found on Apple Music either`
1054 : ` - ${miss.request}: ${trackLineWithAlbum(miss.onAppleMusic)} → ${miss.onAppleMusic.url ?? ''}`,
1055 )
1056 }
1057 }
1058 const { current } = await read($, status)
1059 void refreshQueueIfOpen($, session, current)
1060 return lines.join('\n')
1061}
1062
1063async function control($: EngineInterface, session: Session, source: PlayerSource, command: PlayerCommand): Promise<void> {
1064 try {
1065 const ran = await runScript($, controlScript(source, command))
1066 if (ran.exitCode !== 0) {
1067 $.ui.toast(describeFailure(source, ran.stderr))
1068 }
1069 } catch (error) {
1070 $.ui.toast(`${displayName(source)} did not take the command: ${errorText(error)}`)
1071 }
1072 refreshSoon($, session)
1073}
1074
1075/** Silences the player, or restores the volume it had before the last mute. */
1076async function toggleMute($: EngineInterface, session: Session, current: NowPlaying): Promise<string> {
1077 const remembered = await read($, mutedVolume)
1078 if (current.volume > 0) {
1079 await update($, mutedVolume, () => current.volume)
1080 await control($, session, current.source, { volume: 0 })
1081 return `${displayName(current.source)}: muted.`
1082 }
1083 const restored = remembered ?? UNMUTE_FALLBACK_VOLUME
1084 await update($, mutedVolume, () => null)
1085 await control($, session, current.source, { volume: restored })
1086 return `${displayName(current.source)}: volume ${restored}%.`
1087}
1088
1089async function cycleRepeat($: EngineInterface, session: Session, current: NowPlaying): Promise<string> {
1090 const mode = nextRepeatMode(current.source, current.repeat)
1091 await control($, session, current.source, { repeat: mode })
1092 return `${displayName(current.source)}: repeat ${mode}.`
1093}
1094
1095async function copyShare($: EngineInterface, current: NowPlaying, surface: RenderSurface | undefined): Promise<string> {
1096 const text = shareText(current)
1097 const copied = await $.ui.copy(surface === undefined ? { text } : { text, surface })
1098 const what = current.shareUrl === null ? 'track name' : 'link'
1099 const message = copied.isCopied ? `Copied the ${what}: ${text}` : `Could not copy (${copied.reason}): ${text}`
1100 $.ui.toast(message)
1101 return message
1102}
1103
1104async function openPlayer($: EngineInterface, source: PlayerSource): Promise<string> {
1105 try {
1106 await $.process.run(openAppArgv(source), { timeoutMs: SCRIPT_TIMEOUT_MS })
1107 return `Opened ${displayName(source)}.`
1108 } catch (error) {
1109 return `Could not open ${displayName(source)}: ${errorText(error)}`
1110 }
1111}
1112
1113/** Music searches its library by name and plays the first match. */
1114async function playByName($: EngineInterface, session: Session, query: string): Promise<string> {
1115 if (!(await isMusicRunning($))) {
1116 return "Playing by name needs Music running; Spotify's scripting cannot search."
1117 }
1118 try {
1119 const played = await readScript($, playByNameScript(query), parsePlayed, SEARCH_TIMEOUT_MS)
1120 refreshSoon($, session)
1121 if (played === null) return `Music has no track named like "${query}".`
1122 return `Music: playing ${trackLine(played)}.`
1123 } catch (error) {
1124 return `Music could not search: ${errorText(error)}`
1125 }
1126}
1127
1128/** Keeps the track that just started in the listening history, once per listen. */
1129async function recordListen($: EngineInterface, current: NowPlaying): Promise<void> {
1130 try {
1131 const history = await readHistory($)
1132 const last = history[history.length - 1]
1133 const now = await $.clock.now()
1134 const isRepeat = last !== undefined && last.id === current.trackId && last.source === current.source && now - last.at < HISTORY_REPEAT_WINDOW_MS
1135 if (isRepeat) return
1136 const entry: HistoryEntry = { id: current.trackId, title: current.title, artist: current.artist, album: current.album, source: current.source, at: now }
1137 await $.store.set(STORE_HISTORY, [...history, entry].slice(-HISTORY_KEEP))
1138 } catch (error) {
1139 $.ui.log(`history write failed: ${errorText(error)}`)
1140 }
1141}
1142
1143async function readHistory($: EngineInterface): Promise<HistoryEntry[]> {
1144 const stored = await $.store.get(STORE_HISTORY)
1145 return Array.isArray(stored) ? (stored as HistoryEntry[]) : []
1146}
1147
1148function twoDigits(value: number): string {
1149 return String(value).padStart(2, '0')
1150}
1151
1152/** `14:05` today, `03-10 14:05` on another day; local time. */
1153function formatListenTime(atMs: number, nowMs: number): string {
1154 const at = new Date(atMs)
1155 const now = new Date(nowMs)
1156 const clock = `${twoDigits(at.getHours())}:${twoDigits(at.getMinutes())}`
1157 const sameDay = at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth() && at.getDate() === now.getDate()
1158 return sameDay ? clock : `${twoDigits(at.getMonth() + 1)}-${twoDigits(at.getDate())} ${clock}`
1159}
1160
1161/** The last `limit` listens, newest first, one per line. */
1162async function describeHistory($: EngineInterface, limit: number): Promise<string> {
1163 const history = await readHistory($)
1164 if (history.length === 0) return 'No listening history yet: it fills as tracks play while Claude Code is open.'
1165 const now = await $.clock.now()
1166 const shown = history.slice(-Math.max(1, Math.min(HISTORY_MAX_SHOWN, Math.floor(limit)))).reverse()
1167 const lines = shown.map(entry => `${formatListenTime(entry.at, now)} ${trackLine(entry)}${entry.album ? ` · ${entry.album}` : ''} (${displayName(entry.source)}, id ${entry.id})`)
1168 return [`Last ${shown.length} of ${history.length} listens, newest first:`, ...lines].join('\n')
1169}
1170
1171/** Reads the lyrics of the track on into state, with a line saying why when there are none. */
1172async function refreshLyrics($: EngineInterface, session: Session, current: NowPlaying | null): Promise<LyricsView | null> {
1173 const view = await (async (): Promise<LyricsView | null> => {
1174 if (current === null) return null
1175 if (current.source !== 'music') {
1176 return { id: current.trackId, title: current.title, artist: current.artist, text: '', note: "Spotify's scripting has no lyrics; this works with Apple Music." }
1177 }
1178 try {
1179 const read = await readScript($, lyricsScript(), parseLyrics, SEARCH_TIMEOUT_MS)
1180 if (read === null) return { id: current.trackId, title: current.title, artist: current.artist, text: '', note: 'Music gave no lyrics for this track.' }
1181 const note = read.text === '' ? "Music holds no lyrics for this track. Apple Music's own lyrics are not exposed to scripts; only lyrics stored with a library track show here." : null
1182 return { ...read, note }
1183 } catch (error) {
1184 return { id: current.trackId, title: current.title, artist: current.artist, text: '', note: `Music could not read the lyrics: ${errorText(error)}` }
1185 }
1186 })()
1187 await update($, lyrics, () => view)
1188 return view
1189}
1190
1191async function isLyricsOpen($: EngineInterface): Promise<boolean> {
1192 const panes = await $.ui.panes()
1193 return panes.some(pane => pane.id === LYRICS_PANE)
1194}
1195
1196async function refreshLyricsIfOpen($: EngineInterface, session: Session, current: NowPlaying | null): Promise<void> {
1197 try {
1198 if (await isLyricsOpen($)) await refreshLyrics($, session, current)
1199 } catch (error) {
1200 $.ui.log(`lyrics check failed: ${errorText(error)}`)hooks/format.ts 178 lines1/**
2 * Pure helpers for the band: clocks, truncation, the progress bar, the
3 * position between polls, and which controls fit the width the band is given.
4 */
5import type { NowPlaying } from '../types'
6
7const SECONDS_PER_MINUTE = 60
8const MINUTES_PER_HOUR = 60
9export const MS_PER_SECOND = 1000
10const ELLIPSIS = '…'
11
12const BAR_FILLED = '█'
13const BAR_EMPTY = '░'
14/** The left-aligned partial blocks, by eighths filled: the bar's head moves in sub-cell steps. */
15const BAR_EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
16const EIGHTHS_PER_CELL = BAR_EIGHTHS.length
17
18/** `m:ss`, or `h:mm:ss` past an hour. */
19export function formatClock(totalSeconds: number): string {
20 const whole = Math.max(0, Math.floor(totalSeconds))
21 const seconds = whole % SECONDS_PER_MINUTE
22 const minutes = Math.floor(whole / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR
23 const hours = Math.floor(whole / (SECONDS_PER_MINUTE * MINUTES_PER_HOUR))
24 const paddedSeconds = String(seconds).padStart(2, '0')
25 if (hours > 0) {
26 return `${hours}:${String(minutes).padStart(2, '0')}:${paddedSeconds}`
27 }
28 return `${minutes}:${paddedSeconds}`
29}
30
31/**
32 * The code point ranges a terminal draws two cells wide: the East Asian wide
33 * and fullwidth blocks (Hangul, kana, CJK ideographs and their forms). Emoji
34 * are checked separately, by property.
35 */
36const WIDE_RANGES: readonly (readonly [number, number])[] = [
37 [0x1100, 0x115f],
38 [0x2e80, 0x303e],
39 [0x3041, 0x33ff],
40 [0x3400, 0x4dbf],
41 [0x4e00, 0x9fff],
42 [0xa000, 0xa4cf],
43 [0xac00, 0xd7a3],
44 [0xf900, 0xfaff],
45 [0xfe30, 0xfe4f],
46 [0xff00, 0xff60],
47 [0xffe0, 0xffe6],
48 [0x20000, 0x3fffd],
49]
50/** Pictographs from here on are the colour emoji that take two cells; the older symbols below are one. */
51const FIRST_EMOJI_CODE_POINT = 0x1f000
52const WIDE_CELLS = 2
53const NARROW_CELLS = 1
54
55/**
56 * How many terminal cells one character takes: none for a combining mark or
57 * a zero-width joiner, two for CJK and emoji, one for the rest. An
58 * approximation of wcwidth close enough to keep a title inside its row.
59 */
60function cellsFor(character: string): number {
61 const codePoint = character.codePointAt(0) ?? 0
62 if (/^[\p{M}\u200b-\u200f\ufe0f]$/u.test(character)) return 0
63 if (WIDE_RANGES.some(([first, last]) => codePoint >= first && codePoint <= last)) return WIDE_CELLS
64 if (codePoint >= FIRST_EMOJI_CODE_POINT && /^\p{Extended_Pictographic}$/u.test(character)) return WIDE_CELLS
65 return NARROW_CELLS
66}
67
68/** The terminal cells `text` occupies on one row. */
69export function cellWidth(text: string): number {
70 let cells = 0
71 for (const character of text) cells += cellsFor(character)
72 return cells
73}
74
75/** Cuts `text` to `width` cells, ending with an ellipsis when it was longer. */
76export function truncateText(text: string, width: number): string {
77 if (width <= 0) return ''
78 if (cellWidth(text) <= width) return text
79 if (width === 1) return ELLIPSIS
80 const room = width - cellWidth(ELLIPSIS)
81 let kept = ''
82 let used = 0
83 for (const character of text) {
84 const cells = cellsFor(character)
85 if (used + cells > room) break
86 kept += character
87 used += cells
88 }
89 return kept + ELLIPSIS
90}
91
92/**
93 * The played and unplayed halves of a bar `width` cells wide. The played
94 * half ends in a partial block, so the head advances eight times per cell
95 * rather than jumping a whole cell at a time.
96 */
97export function progressBar(
98 positionSeconds: number,
99 durationSeconds: number,
100 width: number,
101): { played: string; remaining: string } {
102 if (width <= 0) return { played: '', remaining: '' }
103 const fraction = durationSeconds > 0 ? Math.min(1, Math.max(0, positionSeconds / durationSeconds)) : 0
104 const eighths = Math.round(fraction * width * EIGHTHS_PER_CELL)
105 const fullCells = Math.floor(eighths / EIGHTHS_PER_CELL)
106 const head = BAR_EIGHTHS[eighths % EIGHTHS_PER_CELL] ?? ''
107 const playedCells = fullCells + (head === '' ? 0 : 1)
108 return {
109 played: BAR_FILLED.repeat(fullCells) + head,
110 remaining: BAR_EMPTY.repeat(Math.max(0, width - playedCells)),
111 }
112}
113
114/**
115 * Where the track is now: the last reading, moved on by the time since it was
116 * taken while playing, and never past the end.
117 */
118export function positionNow(reading: NowPlaying, nowMs: number): number {
119 if (reading.state !== 'playing') return reading.positionSeconds
120 const elapsed = Math.max(0, nowMs - reading.fetchedAt) / MS_PER_SECOND
121 const moved = reading.positionSeconds + elapsed
122 return reading.durationSeconds > 0 ? Math.min(reading.durationSeconds, moved) : moved
123}
124
125/** Which optional parts of the status row fit a given width. */
126type BandLayout = {
127 showAlbum: boolean
128 showBar: boolean
129 showClock: boolean
130 showVolume: boolean
131 barWidth: number
132}
133
134const WIDE_COLUMNS = 120
135const ROOMY_COLUMNS = 90
136const NARROW_COLUMNS = 60
137const FULL_BAR_WIDTH = 16
138
139export function layoutFor(bodyColumns: number): BandLayout {
140 if (bodyColumns >= WIDE_COLUMNS) {
141 return { showAlbum: true, showBar: true, showClock: true, showVolume: true, barWidth: FULL_BAR_WIDTH }
142 }
143 if (bodyColumns >= ROOMY_COLUMNS) {
144 return { showAlbum: false, showBar: false, showClock: true, showVolume: true, barWidth: 0 }
145 }
146 if (bodyColumns >= NARROW_COLUMNS) {
147 return { showAlbum: false, showBar: false, showClock: true, showVolume: false, barWidth: 0 }
148 }
149 return { showAlbum: false, showBar: false, showClock: false, showVolume: false, barWidth: 0 }
150}
151
152/** A plain Button draws as `b: prev`: the hotkey, a colon, a space, the label. */
153const PLAIN_BUTTON_CHROME = 3
154/** The cells a row's `gap={1}` leaves between two buttons. */
155export const BUTTON_GAP = 1
156
157/** The cells a plain Button with a hotkey takes: `x: label`. */
158export function plainButtonWidth(label: string): number {
159 return cellWidth(label) + PLAIN_BUTTON_CHROME
160}
161
162/**
163 * Keeps, in their given order, the controls that fit `width` when taken by
164 * rising `priority` (lower first): the most-used controls survive a narrow band.
165 */
166export function fitControls<T extends { label: string; priority: number }>(controls: readonly T[], width: number): T[] {
167 const byPriority = [...controls].sort((a, b) => a.priority - b.priority)
168 const kept = new Set<T>()
169 let used = 0
170 for (const control of byPriority) {
171 const cost = plainButtonWidth(control.label) + (kept.size > 0 ? BUTTON_GAP : 0)
172 if (used + cost > width) continue
173 used += cost
174 kept.add(control)
175 }
176 return controls.filter(control => kept.has(control))
177}
178hooks/pixels.ts 125 lines1/**
2 * Tiny pictures for terminals without an image protocol: decoding the small
3 * BMP `sips` writes, shrinking it to a grid, and packing that grid into the
4 * half-block cells a `Raster` element draws, two pixels per cell.
5 */
6import type { Thumbnail } from '../types'
7
8const BMP_HEADER_BYTES = 54
9const BITS_PER_BYTE = 8
10const UPPER_HALF_BLOCK = 0x2580
11const BYTES_PER_CELL_WORD = 4
12const WORDS_PER_CELL = 3
13
14/** One terminal cell: its glyph and the two colours, as `0xRRGGBB`. */
15type Cell = { codePoint: number; foreground: number; background: number }
16
17function readU16(bytes: Uint8Array, offset: number): number {
18 return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8)
19}
20
21function readI32(bytes: Uint8Array, offset: number): number {
22 return ((bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8) | ((bytes[offset + 2] ?? 0) << 16) | ((bytes[offset + 3] ?? 0) << 24)) | 0
23}
24
25/**
26 * Reads an uncompressed 24- or 32-bit BMP into `0xRRGGBB` pixels, row-major
27 * from the top. Returns null for anything else.
28 */
29export function decodeBmp(bytes: Uint8Array): Thumbnail | null {
30 if (bytes.length < BMP_HEADER_BYTES || bytes[0] !== 0x42 || bytes[1] !== 0x4d) return null
31 const pixelOffset = readI32(bytes, 10)
32 const width = readI32(bytes, 18)
33 const rawHeight = readI32(bytes, 22)
34 const bitsPerPixel = readU16(bytes, 28)
35 const compression = readI32(bytes, 30)
36 if (width <= 0 || rawHeight === 0 || compression !== 0) return null
37 if (bitsPerPixel !== 24 && bitsPerPixel !== 32) return null
38
39 const height = Math.abs(rawHeight)
40 const isTopDown = rawHeight < 0
41 const bytesPerPixel = bitsPerPixel / BITS_PER_BYTE
42 const stride = Math.ceil((width * bytesPerPixel) / 4) * 4
43 if (pixelOffset + stride * height > bytes.length) return null
44
45 const pixels: number[] = []
46 for (let row = 0; row < height; row++) {
47 const sourceRow = isTopDown ? row : height - 1 - row
48 const rowStart = pixelOffset + sourceRow * stride
49 for (let column = 0; column < width; column++) {
50 const at = rowStart + column * bytesPerPixel
51 const blue = bytes[at] ?? 0
52 const green = bytes[at + 1] ?? 0
53 const red = bytes[at + 2] ?? 0
54 pixels.push((red << 16) | (green << 8) | blue)
55 }
56 }
57 return { width, height, pixels }
58}
59
60/** Shrinks a picture to `width` by `height` by averaging the pixels each cell covers. */
61export function sampleThumbnail(source: Thumbnail, width: number, height: number): Thumbnail {
62 const pixels: number[] = []
63 for (let row = 0; row < height; row++) {
64 const top = Math.floor((row * source.height) / height)
65 const bottom = Math.max(top + 1, Math.floor(((row + 1) * source.height) / height))
66 for (let column = 0; column < width; column++) {
67 const left = Math.floor((column * source.width) / width)
68 const right = Math.max(left + 1, Math.floor(((column + 1) * source.width) / width))
69 let red = 0
70 let green = 0
71 let blue = 0
72 let count = 0
73 for (let y = top; y < bottom; y++) {
74 for (let x = left; x < right; x++) {
75 const pixel = source.pixels[y * source.width + x] ?? 0
76 red += (pixel >> 16) & 0xff
77 green += (pixel >> 8) & 0xff
78 blue += pixel & 0xff
79 count++
80 }
81 }
82 const average = (channel: number) => Math.round(channel / Math.max(1, count))
83 pixels.push((average(red) << 16) | (average(green) << 8) | average(blue))
84 }
85 }
86 return { width, height, pixels }
87}
88
89/**
90 * Folds a picture into `columns` by `rows` half-block cells: each cell shows
91 * two pixels, the upper as the glyph's colour and the lower as its background.
92 */
93export function thumbnailCells(source: Thumbnail, columns: number, rows: number): Cell[] {
94 const grid = sampleThumbnail(source, columns, rows * 2)
95 const cells: Cell[] = []
96 for (let row = 0; row < rows; row++) {
97 for (let column = 0; column < columns; column++) {
98 const upper = grid.pixels[row * 2 * columns + column] ?? 0
99 const lower = grid.pixels[(row * 2 + 1) * columns + column] ?? 0
100 cells.push({ codePoint: UPPER_HALF_BLOCK, foreground: upper, background: lower })
101 }
102 }
103 return cells
104}
105
106/** Packs cells as `Raster` wants them: little-endian u32 triplets, base64. */
107export function encodeCells(cells: readonly Cell[]): string {
108 const bytes = new Uint8Array(cells.length * WORDS_PER_CELL * BYTES_PER_CELL_WORD)
109 let at = 0
110 for (const cell of cells) {
111 for (const word of [cell.codePoint, cell.foreground, cell.background]) {
112 bytes[at++] = word & 0xff
113 bytes[at++] = (word >> 8) & 0xff
114 bytes[at++] = (word >> 16) & 0xff
115 bytes[at++] = (word >> 24) & 0xff
116 }
117 }
118 return btoa(String.fromCharCode(...bytes))
119}
120
121/** The bytes behind the base64 the host hands over, as `$.fs.read(path, { as: 'bytes' })` answers. */
122export function bytesFromBase64(text: string): Uint8Array {
123 return Uint8Array.from(atob(text), character => character.charCodeAt(0))
124}
125hooks/players.ts 961 lines1/**
2 * Talking to Apple Music and Spotify: the AppleScript each query and control
3 * runs, how a reply is parsed, and which player the band follows.
4 *
5 * Every script is guarded by `is running` so the mod never launches a player,
6 * and every `tell` names the app literally, which AppleScript needs to compile
7 * its vocabulary. A player that is not running is found with pgrep first, so
8 * no script is compiled against an app that may not be installed.
9 */
10import type { NowPlaying, PlaybackState, PlayerSource, QueueEntry, RepeatMode, SearchResult } from '../types'
11
12const SOURCES: readonly PlayerSource[] = ['spotify', 'music']
13
14/** What sets the two players apart: their names, the words their scripting uses, and how repeat cycles. */
15type PlayerProfile = {
16 name: string
17 /** The status fields each player spells differently; `tr` is the current track. */
18 fields: {
19 duration: string
20 trackId: string
21 shuffle: string
22 repeat: string
23 shareUrl: string
24 artworkUrl: string
25 }
26 toggleShuffle: string
27 setRepeat: (mode: RepeatMode) => string
28 /** The repeat modes in the order the repeat control cycles them. */
29 repeatCycle: readonly RepeatMode[]
30}
31
32const PLAYERS: Readonly<Record<PlayerSource, PlayerProfile>> = {
33 spotify: {
34 name: 'Spotify',
35 fields: {
36 // Spotify reports a track's duration in milliseconds, Music in seconds.
37 duration: '((duration of tr) / 1000)',
38 trackId: 'id of tr',
39 shuffle: 'shuffling',
40 repeat: 'repeating',
41 shareUrl: 'spotify url of tr',
42 artworkUrl: 'artwork url of tr',
43 },
44 toggleShuffle: 'set shuffling to not shuffling',
45 setRepeat: mode => `set repeating to ${mode !== 'off'}`,
46 repeatCycle: ['off', 'all'],
47 },
48 music: {
49 name: 'Music',
50 fields: {
51 duration: 'duration of tr',
52 trackId: 'persistent ID of tr',
53 shuffle: 'shuffle enabled',
54 repeat: 'song repeat',
55 shareUrl: '""',
56 artworkUrl: '""',
57 },
58 toggleShuffle: 'set shuffle enabled to not shuffle enabled',
59 setRepeat: mode => `set song repeat to ${mode}`,
60 repeatCycle: ['off', 'all', 'one'],
61 },
62}
63
64/** Separates the fields a status script returns; never appears in a track name. */
65const FIELD_SEPARATOR = '\u001f'
66/** Ends each query's block in a batched search reply. */
67const RECORD_SEPARATOR = '\u001e'
68const STATUS_FIELD_COUNT = 12
69const SCRIPT_TIMEOUT_SECONDS = 3
70/** Listing a playlist or writing a cover takes Music longer than a status read. */
71const LISTING_TIMEOUT_SECONDS = 5
72const SEARCH_TIMEOUT_SECONDS = 10
73/** How long curl may spend fetching one cover. */
74const DOWNLOAD_TIMEOUT_SECONDS = 8
75
76const MIN_VOLUME = 0
77const MAX_VOLUME = 100
78
79/** How many tracks the queue pane lists, the track on included. */
80const QUEUE_LENGTH = 20
81/** How many already-played tracks the queue shows above the one on. */
82const QUEUE_LOOKBACK = 2
83/** How many of the mod's playlist's tracks the pane lists: the last ones added. */
84const PLAYLIST_LIST_LENGTH = 30
85/** How many matches a library search returns at most. */
86export const MAX_SEARCH_RESULTS = 25
87
88export type PlayerCommand =
89 | 'play'
90 | 'pause'
91 | 'playpause'
92 | 'next'
93 | 'previous'
94 | 'toggleShuffle'
95 | { volume: number }
96 | { repeat: RepeatMode }
97 | { playIndex: number }
98
99export function displayName(source: PlayerSource): string {
100 return PLAYERS[source].name
101}
102
103/** `pgrep` arguments that list the players running right now. */
104export function runningPlayersArgv(): readonly string[] {
105 return ['pgrep', '-l', '-x', SOURCES.map(source => PLAYERS[source].name).join('|')]
106}
107
108/** Reads the players `pgrep -l` listed, by their process names. */
109export function parseRunningPlayers(stdout: string): PlayerSource[] {
110 const names = new Set(
111 stdout
112 .split('\n')
113 .map(line => line.trim().split(/\s+/).slice(1).join(' '))
114 .filter(name => name.length > 0),
115 )
116 return SOURCES.filter(source => names.has(PLAYERS[source].name))
117}
118
119export function osascriptArgv(script: string): readonly string[] {
120 return ['osascript', '-e', script]
121}
122
123/** Brings the player's window to the front. */
124export function openAppArgv(source: PlayerSource): readonly string[] {
125 return ['open', '-a', PLAYERS[source].name]
126}
127
128/** Downloads a cover from its URL to `path`. */
129export function downloadArgv(url: string, path: string): readonly string[] {
130 return ['curl', '-fsSL', '--max-time', String(DOWNLOAD_TIMEOUT_SECONDS), '-o', path, url]
131}
132
133/** Converts whatever picture `source` holds into a PNG at `target`. */
134export function toPngArgv(source: string, target: string): readonly string[] {
135 return ['sips', '-s', 'format', 'png', source, '--out', target]
136}
137
138/** Shrinks the picture at `source` to `size` by `size` pixels as a BMP at `target`. */
139export function toThumbnailArgv(source: string, target: string, size: number): readonly string[] {
140 const side = String(Math.max(1, Math.floor(size)))
141 return ['sips', '-z', side, side, '-s', 'format', 'bmp', source, '--out', target]
142}
143
144/** Quotes `text` for use inside an AppleScript string literal. */
145function escapeAppleScript(text: string): string {
146 return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, ' ')
147}
148
149/** `set <name> to ""` then a guarded read of `expression`, so one missing field never fails the script. */
150function guardedField(name: string, expression: string): string[] {
151 return [`set ${name} to ""`, 'try', ` set ${name} to (${expression}) as text`, 'end try']
152}
153
154function guarded(source: PlayerSource, timeoutSeconds: number, body: readonly string[]): string {
155 const appName = PLAYERS[source].name
156 return [
157 `if application "${appName}" is not running then return "off"`,
158 'set sep to character id 31',
159 `with timeout of ${timeoutSeconds} seconds`,
160 ` tell application "${appName}"`,
161 ...body.map(line => ` ${line}`),
162 ' end tell',
163 'end timeout',
164 ].join('\n')
165}
166
167/** A one-line script that sends `verb` to the player only while it is running. */
168function tellIfRunning(source: PlayerSource, verb: string): string {
169 const appName = PLAYERS[source].name
170 return `if application "${appName}" is running then tell application "${appName}" to ${verb}`
171}
172
173/**
174 * Inside a `repeat with k from ...` over playlist `pl`: appends track k's
175 * fields to `out` as one line. Only `index` and `track k of playlist` agree on
176 * the order; a range (`tracks i thru j`) and `every track` each follow
177 * another, so tracks are read one at a time.
178 */
179function appendTrackLine(): string[] {
180 return [
181 ...guardedField('trackTitle', 'get name of track k of pl'),
182 ...guardedField('trackArtist', 'get artist of track k of pl'),
183 ...guardedField('trackAlbum', 'get album of track k of pl'),
184 ...guardedField('trackId', 'get persistent ID of track k of pl'),
185 'set out to out & (k as text) & sep & trackTitle & sep & trackArtist & sep & trackAlbum & sep & trackId & linefeed',
186 ]
187}
188
189/**
190 * Writes the bytes in `pictureBytes` to the file at `path`, replacing it.
191 * The handle is closed on a failed write too: one left open stays open in
192 * Music's process, and the next export of that file is refused.
193 */
194function writePictureLines(path: string): string[] {
195 return [
196 `set target to POSIX file "${escapeAppleScript(path)}"`,
197 'set handle to open for access target with write permission',
198 'try',
199 ' set eof handle to 0',
200 ' write pictureBytes to handle',
201 ' close access handle',
202 'on error writeError',
203 ' close access handle',
204 ' error writeError',
205 'end try',
206 ]
207}
208
209/** The AppleScript that reads what `source` is playing, one line of fields. */
210export function statusScript(source: PlayerSource): string {
211 const { fields } = PLAYERS[source]
212 return guarded(source, SCRIPT_TIMEOUT_SECONDS, [
213 'set playerState to (player state as text)',
214 'if playerState is "stopped" then return "stopped"',
215 'set tr to current track',
216 ...guardedField('trackName', 'name of tr'),
217 ...guardedField('trackArtist', 'artist of tr'),
218 ...guardedField('trackAlbum', 'album of tr'),
219 ...guardedField('trackDuration', fields.duration),
220 ...guardedField('trackId', fields.trackId),
221 ...guardedField('shuffleText', fields.shuffle),
222 ...guardedField('repeatText', fields.repeat),
223 ...guardedField('shareUrl', fields.shareUrl),
224 ...guardedField('artworkUrl', fields.artworkUrl),
225 'return playerState & sep & trackName & sep & trackArtist & sep & trackAlbum' +
226 ' & sep & (player position as text) & sep & trackDuration & sep & (sound volume as text)' +
227 ' & sep & trackId & sep & shuffleText & sep & repeatText & sep & shareUrl & sep & artworkUrl',
228 ])
229}
230
231/** The AppleScript that sends `command` to `source`. */
232export function controlScript(source: PlayerSource, command: PlayerCommand): string {
233 return tellIfRunning(source, verbFor(source, command))
234}
235
236function verbFor(source: PlayerSource, command: PlayerCommand): string {
237 const player = PLAYERS[source]
238 if (typeof command === 'object') {
239 if ('volume' in command) return `set sound volume to ${clampVolume(command.volume)}`
240 if ('playIndex' in command) return `play track ${Math.max(1, Math.floor(command.playIndex))} of current playlist`
241 return player.setRepeat(command.repeat)
242 }
243 switch (command) {
244 case 'play':
245 return 'play'
246 case 'pause':
247 return 'pause'
248 case 'playpause':
249 return 'playpause'
250 case 'next':
251 return 'next track'
252 case 'previous':
253 return 'previous track'
254 case 'toggleShuffle':
255 return player.toggleShuffle
256 }
257}
258
259/** The repeat mode after this one in the player's cycle: off, all, one (Spotify skips `one`). */
260export function nextRepeatMode(source: PlayerSource, current: RepeatMode): RepeatMode {
261 const cycle = PLAYERS[source].repeatCycle
262 return cycle[(cycle.indexOf(current) + 1) % cycle.length] ?? 'off'
263}
264
265/** Music plays the first library track whose name contains `query`, and says which. */
266export function playByNameScript(query: string): string {
267 return guarded('music', SEARCH_TIMEOUT_SECONDS, [
268 `set found to (first track of library playlist 1 whose name contains "${escapeAppleScript(query)}")`,
269 'play found',
270 'return (name of found) & sep & (artist of found)',
271 ])
272}
273
274/** Reads a `playByNameScript` reply: the track that started, or null. */
275export function parsePlayed(stdout: string): { title: string; artist: string } | null {
276 const line = stdout.trim()
277 if (line === '' || line === 'off') return null
278 const [title = '', artist = ''] = line.split(FIELD_SEPARATOR)
279 return title === '' ? null : { title, artist }
280}
281
282/** Music lists the tracks around the one on in its current playlist. */
283export function queueScript(): string {
284 return guarded('music', LISTING_TIMEOUT_SECONDS, [
285 'if (player state as text) is "stopped" then return ""',
286 'set pl to current playlist',
287 'set i to index of current track',
288 'set n to count of tracks of pl',
289 `set firstIndex to i - ${QUEUE_LOOKBACK}`,
290 'if firstIndex < 1 then set firstIndex to 1',
291 `set lastIndex to firstIndex + ${QUEUE_LENGTH - 1}`,
292 'if lastIndex > n then set lastIndex to n',
293 'set out to (i as text) & sep & (get name of pl) & linefeed',
294 'repeat with k from firstIndex to lastIndex',
295 ...appendTrackLine(),
296 'end repeat',
297 'return out',
298 ])
299}
300
301type ParsedQueue = { currentIndex: number; playlistName: string; entries: QueueEntry[] }
302
303/** The non-empty lines of a reply, or null when the player was off or said nothing. */
304function replyLines(stdout: string): string[] | null {
305 const lines = stdout.split('\n').filter(line => line.trim() !== '')
306 const first = lines[0]
307 return first === undefined || first === 'off' ? null : lines
308}
309
310/** Reads the lines `appendTrackLine` wrote, one entry per well-formed line. */
311function parseTrackLines(lines: readonly string[]): QueueEntry[] {
312 const entries: QueueEntry[] = []
313 for (const line of lines) {
314 const [indexText = '', title = '', artist = '', album = '', id = ''] = line.split(FIELD_SEPARATOR)
315 const index = Number.parseInt(indexText, 10)
316 if (Number.isFinite(index)) entries.push({ index, title, artist, album, id })
317 }
318 return entries
319}
320
321/** Reads a `queueScript` reply: the track on and its playlist, then the entries. */
322export function parseQueue(stdout: string): ParsedQueue | null {
323 const lines = replyLines(stdout)
324 if (lines === null) return null
325 const [first = '', ...rest] = lines
326 const [indexText = '', playlistName = ''] = first.split(FIELD_SEPARATOR)
327 const currentIndex = Number.parseInt(indexText, 10)
328 if (!Number.isFinite(currentIndex)) return null
329 return { currentIndex, playlistName, entries: parseTrackLines(rest) }
330}
331
332/** Music writes the current track's cover bytes to `path` and says their format. */
333export function artworkExportScript(path: string): string {
334 return guarded('music', LISTING_TIMEOUT_SECONDS, [
335 'if (player state as text) is "stopped" then return ""',
336 'if (count of artworks of current track) is 0 then return ""',
337 'set art to artwork 1 of current track',
338 'set pictureBytes to (get raw data of art)',
339 ...writePictureLines(path),
340 'return (format of art) as text',
341 ])
342}
343
344/** Music writes the cover of the library track with this persistent ID to `path`. */
345export function artworkByIdScript(id: string, path: string): string {
346 return guarded('music', LISTING_TIMEOUT_SECONDS, [
347 `set t to (first track of library playlist 1 whose persistent ID is "${escapeAppleScript(id)}")`,
348 'if (count of artworks of t) is 0 then return ""',
349 'set pictureBytes to (get raw data of artwork 1 of t)',
350 ...writePictureLines(path),
351 'return "ok"',
352 ])
353}
354
355/** `limit` held to 1 through `MAX_SEARCH_RESULTS`. */
356function capResults(limit: number): number {
357 return Math.max(1, Math.min(MAX_SEARCH_RESULTS, Math.floor(limit)))
358}
359
360/**
361 * Inside a script with `out` set: Music searches the library for the text
362 * `needleExpression` names and appends up to `limit` matches to `out`, one
363 * line each.
364 */
365function appendSearchLines(needleExpression: string, limit: number): string[] {
366 return [
367 `set found to (search library playlist 1 for ${needleExpression})`,
368 'set n to count of found',
369 `repeat with k from 1 to ${capResults(limit)}`,
370 ' if k > n then exit repeat',
371 ' set t to item k of found',
372 ...guardedField('trackName', 'name of t'),
373 ...guardedField('trackArtist', 'artist of t'),
374 ...guardedField('trackAlbum', 'album of t'),
375 ' set out to out & (get persistent ID of t) & sep & trackName & sep & trackArtist & sep & trackAlbum & linefeed',
376 'end repeat',
377 ]
378}
379
380/**
381 * Music searches its library the way its own search box does: every word of
382 * `query` matched across title, artist, album and the rest, results ranked.
383 */
384export function searchLibraryScript(query: string, limit: number = MAX_SEARCH_RESULTS): string {
385 const needle = escapeAppleScript(query)
386 return guarded('music', SEARCH_TIMEOUT_SECONDS, ['set out to ""', ...appendSearchLines(`"${needle}"`, limit), 'return out'])
387}
388
389/**
390 * One script that runs every search in `queries` in turn, so a batch of
391 * requests costs one osascript launch rather than one per request. The reply
392 * holds one block per query, in order, each ended by a record separator; a
393 * search that fails leaves its block empty.
394 */
395export function searchLibraryManyScript(queries: readonly string[], limit: number = MAX_SEARCH_RESULTS): string {
396 const list = queries.map(query => `"${escapeAppleScript(query)}"`).join(', ')
397 return guarded('music', SEARCH_TIMEOUT_SECONDS, [
398 'set rs to character id 30',
399 `set queries to {${list}}`,
400 'set out to ""',
401 'repeat with q from 1 to count of queries',
402 ' set needle to item q of queries',
403 ' try',
404 ...appendSearchLines('needle', limit).map(line => ` ${line}`),
405 ' end try',
406 ' set out to out & rs',
407 'end repeat',
408 'return out',
409 ])
410}
411
412/** Reads a `searchLibraryManyScript` reply: one list of results per query, in the order asked. */
413export function parseSearchMany(stdout: string, queryCount: number): SearchResult[][] {
414 if (stdout.trim() === 'off') return Array.from({ length: queryCount }, () => [])
415 const blocks = stdout.split(RECORD_SEPARATOR).map(parseSearch)
416 return Array.from({ length: queryCount }, (_, index) => blocks[index] ?? [])
417}
418
419/** Reads a `searchLibraryScript` reply. */
420export function parseSearch(stdout: string): SearchResult[] {
421 const results: SearchResult[] = []
422 for (const line of stdout.split('\n')) {
423 if (line.trim() === '' || line === 'off') continue
424 const [id = '', title = '', artist = '', album = ''] = line.split(FIELD_SEPARATOR)
425 if (id !== '') results.push({ id, kind: 'library', title, artist, album, url: null })
426 }
427 return results
428}
429
430/** Apple's public catalogue search, no sign-in needed; `country` is the storefront (GB, US). */
431export function catalogueSearchUrl(query: string, country: string, limit: number = MAX_SEARCH_RESULTS): string {
432 const params = new URLSearchParams({
433 term: query,
434 media: 'music',
435 entity: 'song',
436 limit: String(capResults(limit)),
437 country: storefrontFrom(country),
438 })
439 return `https://itunes.apple.com/search?${params.toString()}`
440}
441
442/** The two-letter storefront in a locale (`en_GB` → `GB`); `US` when none is found. */
443export function storefrontFrom(locale: string): string {
444 const match = /[_-]([A-Za-z]{2})\b/.exec(locale.trim())
445 const country = match?.[1] ?? (/^[A-Za-z]{2}$/.test(locale.trim()) ? locale.trim() : 'US')
446 return country.toUpperCase()
447}
448
449/** A web link to a track becomes the `music://` link that opens in the Music app. */
450export function musicAppUrl(webUrl: string): string {
451 return webUrl.replace(/^https?:\/\//, 'music://')
452}
453
454/** Reads the JSON Apple's catalogue search answers. */
455export function parseCatalogue(json: string): SearchResult[] {
456 let parsed: unknown
457 try {
458 parsed = JSON.parse(json)
459 } catch {
460 return []
461 }
462 if (typeof parsed !== 'object' || parsed === null || !Array.isArray((parsed as { results?: unknown }).results)) return []
463 const results: SearchResult[] = []
464 for (const item of (parsed as { results: unknown[] }).results) {
465 if (typeof item !== 'object' || item === null) continue
466 const row = item as Record<string, unknown>
467 const title = typeof row.trackName === 'string' ? row.trackName : ''
468 const artist = typeof row.artistName === 'string' ? row.artistName : ''
469 const album = typeof row.collectionName === 'string' ? row.collectionName : ''
470 const id = typeof row.trackId === 'number' ? String(row.trackId) : ''
471 const webUrl = typeof row.trackViewUrl === 'string' ? row.trackViewUrl : ''
472 if (title === '' || id === '' || webUrl === '') continue
473 results.push({ id, kind: 'catalogue', title, artist, album, url: musicAppUrl(webUrl) })
474 }
475 return results
476}
477
478/** Catalogue results the library already holds are dropped: the library row is the one to add. */
479export function withoutLibraryDuplicates(catalogue: readonly SearchResult[], library: readonly SearchResult[]): SearchResult[] {
480 const owned = new Set(library.map(result => `${normalise(result.title)}|${normalise(result.artist)}`))
481 const seen = new Set<string>()
482 return catalogue.filter(result => {
483 const key = `${normalise(result.title)}|${normalise(result.artist)}`
484 if (owned.has(key) || seen.has(key)) return false
485 seen.add(key)
486 return true
487 })
488}
489
490/** Music makes the mod's playlist when it is missing and lists its last tracks. */
491export function ensurePlaylistScript(name: string): string {
492 const playlist = escapeAppleScript(name)
493 return guarded('music', SEARCH_TIMEOUT_SECONDS, [
494 `if not (exists user playlist "${playlist}") then make new user playlist with properties {name:"${playlist}"}`,
495 `set pl to user playlist "${playlist}"`,
496 'set playlistCount to count of tracks of pl',
497 `set firstIndex to playlistCount - ${PLAYLIST_LIST_LENGTH - 1}`,
498 'if firstIndex < 1 then set firstIndex to 1',
499 'set out to (playlistCount as text) & linefeed',
500 'repeat with k from firstIndex to playlistCount',
501 ...appendTrackLine(),
502 'end repeat',
503 'return out',
504 ])
505}
506
507/** Reads an `ensurePlaylistScript` reply: how many tracks, then the last of them. */
508export function parsePlaylist(stdout: string): { count: number; entries: QueueEntry[] } | null {
509 const lines = replyLines(stdout)
510 if (lines === null) return null
511 const [first = '', ...rest] = lines
512 const count = Number.parseInt(first, 10)
513 if (!Number.isFinite(count)) return null
514 return { count, entries: parseTrackLines(rest) }
515}
516
517/** Music starts the mod's playlist at its `index`th track (from 1). */
518export function playPlaylistTrackScript(name: string, index: number): string {
519 const position = Math.max(1, Math.floor(index))
520 return tellIfRunning('music', `play track ${position} of user playlist "${escapeAppleScript(name)}"`)
521}
522
523/** Music starts the mod's playlist from its first track. */
524export function playPlaylistScript(name: string): string {
525 return tellIfRunning('music', `play user playlist "${escapeAppleScript(name)}"`)
526}
527
528/**
529 * Music adds the library tracks with these persistent IDs to the mod's
530 * playlist, skipping any already in it, and answers `added<sep>skipped<sep>count`.
531 */
532export function addTracksScript(name: string, ids: readonly string[]): string {
533 const playlist = escapeAppleScript(name)
534 const idList = ids.map(id => `"${escapeAppleScript(id)}"`).join(', ')
535 return guarded('music', SEARCH_TIMEOUT_SECONDS, [
536 `if not (exists user playlist "${playlist}") then make new user playlist with properties {name:"${playlist}"}`,
537 `set pl to user playlist "${playlist}"`,
538 `set wanted to {${idList}}`,
539 'set added to 0',
540 'set skipped to 0',
541 'repeat with wantedRef in wanted',
542 // The loop variable is a reference into the list; the string itself is what the filters compare.
543 ' set wantedId to contents of wantedRef',
544 ' set already to (count of (tracks of pl whose persistent ID is wantedId))',
545 ' if already > 0 then',
546 ' set skipped to skipped + 1',
547 ' else',
548 ' try',
549 ' duplicate (first track of library playlist 1 whose persistent ID is wantedId) to pl',
550 ' set added to added + 1',
551 ' on error',
552 ' set skipped to skipped + 1',
553 ' end try',
554 ' end if',
555 'end repeat',
556 'return (added as text) & sep & (skipped as text) & sep & ((count of tracks of pl) as text)',
557 ])
558}
559
560/** What an add did: tracks added, tracks already there, and the playlist's size after. */
561export type AddOutcome = { added: number; skipped: number; count: number }
562
563/** Reads an `addTracksScript` reply. */
564export function parseAdded(stdout: string): AddOutcome | null {
565 const line = stdout.trim()
566 if (line === '' || line === 'off') return null
567 const [added = '', skipped = '', count = ''] = line.split(FIELD_SEPARATOR).map(part => part.trim())
568 const numbers = [added, skipped, count].map(text => Number.parseInt(text, 10))
569 if (numbers.some(value => !Number.isFinite(value))) return null
570 return { added: numbers[0] ?? 0, skipped: numbers[1] ?? 0, count: numbers[2] ?? 0 }
571}
572
573/**
574 * Music removes every track from the mod's playlist, leaving the playlist
575 * itself and the library untouched, and answers how many it removed. A
576 * playlist that does not exist counts as empty.
577 */
578export function clearPlaylistScript(name: string): string {
579 const playlist = escapeAppleScript(name)
580 return guarded('music', SEARCH_TIMEOUT_SECONDS, [
581 `if not (exists user playlist "${playlist}") then return "0"`,
582 `set pl to user playlist "${playlist}"`,
583 // `removed` is a reserved word in Music's dictionary, so the count has a longer name.
584 'set trackTotal to count of tracks of pl',
585 'if trackTotal > 0 then delete every track of pl',
586 'return (trackTotal as text)',
587 ])
588}
589
590/** Reads a `clearPlaylistScript` reply: how many tracks were removed. */
591export function parseCleared(stdout: string): number | null {
592 return parseCount(stdout)
593}
594
595/**
596 * `Title — Artist`, `Title - Artist` or `Title by Artist` into its two parts.
597 * A dash is tried before `by`, and `by` is split at its last occurrence, so
598 * titles such as "Stand by Me — Ben E. King" or "Killed by Death by Motörhead"
599 * keep their `by`. A lone title containing `by` cannot be told from one with
600 * an artist, so Claude is asked to name tracks with a dash.
601 */
602export function splitTrackQuery(query: string): { title: string; artist: string | null } {
603 const trimmed = query.trim()
604 const dash = /^(.+?)\s+(?:—|–|-)\s+(.+)$/.exec(trimmed)
605 if (dash !== null) return trackParts(dash[1] ?? '', dash[2] ?? '')
606 const by = /^(.+)\s+by\s+(.+)$/i.exec(trimmed)
607 if (by !== null) return trackParts(by[1] ?? '', by[2] ?? '')
608 return { title: trimmed, artist: null }
609}
610
611function trackParts(title: string, artist: string): { title: string; artist: string | null } {
612 return { title: title.trim(), artist: artist.trim() || null }
613}
614
615/**
616 * A title or artist as compared: lower case, without accents or apostrophes,
617 * without a "(feat. X)" credit, and with runs of anything else as one space,
618 * so "Damselfly (feat. Tom Misch)" and "damselfly" are the same song and
619 * "Beyoncé" finds "Beyonce".
620 */
621export function normalise(text: string): string {
622 return text
623 .normalize('NFD')
624 .replace(/[\u0300-\u036f]/g, '')
625 .toLowerCase()
626 .replace(/[([][^)\]]*\b(?:feat|ft|featuring)\b[^)\]]*[)\]]/g, ' ')
627 .replace(/\s\b(?:feat|ft|featuring)\b\.?\s.*$/, ' ')
628 .replace(/['’`]/g, '')
629 .replace(/[^\p{L}\p{N}]+/gu, ' ')
630 .trim()
631}
632
633/** Returns a reply that is a single count, or null for off, empty or garbled. */
634export function parseCount(stdout: string): number | null {
635 const line = stdout.trim()
636 if (line === '' || line === 'off') return null
637 const count = Number.parseInt(line, 10)
638 return Number.isFinite(count) ? count : null
639}
640
641/**
642 * What a filtered library search asks for; the fields given are combined with
643 * "and", and none at all means the whole library (for a random pick).
644 */
645export type LibraryFilter = {
646 title?: string
647 artist?: string
648 album?: string
649 genre?: string
650 yearFrom?: number
651 yearTo?: number
652 /** Stars from 1 to 5; Music keeps a rating from 0 to 100 in steps of 20. */
653 minStars?: number
654 /** Music's favourite (the heart; `favorited` since macOS 14). */
655 favourite?: boolean
656 minPlays?: number
657 maxPlays?: number
658 /** Tracks not played for this many days, never-played ones included. */
659 notPlayedForDays?: number
660}
661
662/** A library track with the facts a filter can sort by. */
663export type LibraryTrack = SearchResult & { year: number; genre: string; stars: number; playCount: number }
664
665export type LibrarySort = 'random' | 'leastPlayed' | 'mostPlayed' | 'newest' | 'oldest' | 'topRated' | 'title'
666
667const STARS_TO_RATING = 20
668
669function wholeNumber(value: number | undefined): number | null {
670 return value !== undefined && Number.isFinite(value) ? Math.floor(value) : null
671}
672
673/** The `whose` conditions a filter stands for, as Music's scripting spells them. */
674function filterConditions(filter: LibraryFilter): string[] {
675 const conditions = ['media kind is song']
676 const texts: [string, string | undefined][] = [['name', filter.title], ['artist', filter.artist], ['album', filter.album], ['genre', filter.genre]]
677 for (const [property, value] of texts) {
678 if (value !== undefined && value.trim() !== '') conditions.push(`${property} contains "${escapeAppleScript(value.trim())}"`)
679 }
680 const yearFrom = wholeNumber(filter.yearFrom)
681 const yearTo = wholeNumber(filter.yearTo)
682 const minStars = wholeNumber(filter.minStars)
683 const minPlays = wholeNumber(filter.minPlays)
684 const maxPlays = wholeNumber(filter.maxPlays)
685 if (yearFrom !== null) conditions.push(`year >= ${yearFrom}`)
686 if (yearTo !== null) conditions.push(`year <= ${yearTo}`)
687 if (minStars !== null && minStars > 0) conditions.push(`rating >= ${Math.min(5, minStars) * STARS_TO_RATING}`)
688 if (filter.favourite === true) conditions.push('favorited is true')
689 if (minPlays !== null) conditions.push(`played count >= ${minPlays}`)
690 if (maxPlays !== null) conditions.push(`played count <= ${maxPlays}`)
691 if (wholeNumber(filter.notPlayedForDays) !== null) conditions.push('(played date < staleDate or played count = 0)')
692 return conditions
693}
694
695/**
696 * Music lists every library song a filter matches, as one line per property
697 * (IDs, names, artists, albums, years, genres, ratings, play counts), each a
698 * separated list: eight reads of a filtered reference, which Music answers in
699 * well under a second for hundreds of tracks. The caller sorts and trims.
700 */
701export function filterLibraryScript(filter: LibraryFilter): string {
702 const staleDays = wholeNumber(filter.notPlayedForDays)
703 const lists = ['persistent ID', 'name', 'artist', 'album', 'year', 'genre', 'rating', 'played count']
704 return guarded('music', SEARCH_TIMEOUT_SECONDS, [
705 ...(staleDays === null ? [] : [`set staleDate to (current date) - (${Math.max(0, staleDays)} * days)`]),
706 // `matched` and `removed` are reserved words here; `hits` is not.
707 `set hits to a reference to (every track of library playlist 1 whose ${filterConditions(filter).join(' and ')})`,
708 'set n to count of hits',
709 "set AppleScript's text item delimiters to sep",
710 'set out to (n as text) & linefeed',
711 'if n > 0 then',
712 ...lists.map(property => ` set out to out & ((${property} of hits) as text) & linefeed`),
713 'end if',
714 'return out',
715 ])
716}
717
718/**
719 * Reads a `filterLibraryScript` reply. Null for a player that is off or a
720 * reply whose lists do not line up (a name holding a line break would do it).
721 */
722export function parseLibraryTracks(stdout: string): LibraryTrack[] | null {
723 const lines = stdout.split('\n')
724 const count = Number.parseInt(lines[0]?.trim() ?? '', 10)
725 if (lines[0]?.trim() === 'off' || !Number.isFinite(count)) return null
726 if (count === 0) return []
727 const columns = lines.slice(1, 9).map(line => line.split(FIELD_SEPARATOR))
728 const [ids = [], titles = [], artists = [], albums = [], years = [], genres = [], ratings = [], plays = []] = columns
729 if (ids.length !== count || titles.length !== count) return null
730 return ids.map((id, index) => ({
731 id,
732 kind: 'library' as const,
733 title: titles[index] ?? '',
734 artist: artists[index] ?? '',
735 album: albums[index] ?? '',
736 url: null,
737 year: Number.parseInt(years[index] ?? '', 10) || 0,
738 genre: genres[index] ?? '',
739 stars: Math.round((Number.parseInt(ratings[index] ?? '', 10) || 0) / STARS_TO_RATING),
740 playCount: Number.parseInt(plays[index] ?? '', 10) || 0,
741 }))
742}
743
744/** The first `limit` tracks in the order asked; random is a fair shuffle. */
745export function pickTracks(tracks: readonly LibraryTrack[], sort: LibrarySort, limit: number, random: () => number = Math.random): LibraryTrack[] {
746 const list = [...tracks]
747 if (sort === 'random') {
748 for (let i = list.length - 1; i > 0; i--) {
749 const j = Math.floor(random() * (i + 1))
750 const swap = list[i] as LibraryTrack
751 list[i] = list[j] as LibraryTrack
752 list[j] = swap
753 }
754 } else {
755 const compare: Record<Exclude<LibrarySort, 'random'>, (a: LibraryTrack, b: LibraryTrack) => number> = {
756 leastPlayed: (a, b) => a.playCount - b.playCount,
757 mostPlayed: (a, b) => b.playCount - a.playCount,
758 newest: (a, b) => b.year - a.year,
759 oldest: (a, b) => a.year - b.year,
760 topRated: (a, b) => b.stars - a.stars || b.playCount - a.playCount,
761 title: (a, b) => a.title.localeCompare(b.title),
762 }
763 list.sort(compare[sort])
764 }
765 return list.slice(0, Math.max(0, Math.floor(limit)))
766}
767
768/** Music takes these tracks out of the mod's playlist (the library keeps them) and answers how many remain. */
769export function removeFromPlaylistScript(name: string, ids: readonly string[]): string {
770 const playlist = escapeAppleScript(name)
771 const idList = ids.map(id => `"${escapeAppleScript(id)}"`).join(', ')
772 return guarded('music', SEARCH_TIMEOUT_SECONDS, [
773 `if not (exists user playlist "${playlist}") then return "0"`,
774 `set pl to user playlist "${playlist}"`,
775 `set wanted to {${idList}}`,
776 'repeat with wantedRef in wanted',
777 ' set wantedId to contents of wantedRef',
778 ' try',
779 ' delete (first track of pl whose persistent ID is wantedId)',
780 ' end try',
781 'end repeat',
782 'return ((count of tracks of pl) as text)',
783 ])
784}
785
786/** The lyrics Music holds for the track on, with the track's identity. */
787export type Lyrics = { id: string; title: string; artist: string; text: string }
788
789/** Music gives the track on and its lyrics (empty when it has none); `stopped` gives nothing. */
790export function lyricsScript(): string {
791 return guarded('music', LISTING_TIMEOUT_SECONDS, [
792 'if player state is stopped then return ""',
793 'set t to current track',
794 'set ly to ""',
795 'try',
796 ' set ly to lyrics of t',
797 'end try',
798 'return (get persistent ID of t) & sep & (get name of t) & sep & (get artist of t) & sep & ly',
799 ])
800}
801
802/** Reads a `lyricsScript` reply; null for nothing on or Music off. */
803export function parseLyrics(stdout: string): Lyrics | null {
804 const trimmed = stdout.replace(/\s+$/, '')
805 if (trimmed === '' || trimmed === 'off') return null
806 const [id = '', title = '', artist = '', ...rest] = trimmed.split(FIELD_SEPARATOR)
807 if (id === '') return null
808 return { id, title, artist, text: rest.join(FIELD_SEPARATOR).trim() }
809}
810
811/**
812 * The result that best answers a request: an exact title by the named artist
813 * first, then an exact title, then a title that contains (or is contained in)
814 * the request, by that artist first. The title must match: Music's search
815 * matches any word, so a loose first result could be the wrong song.
816 */
817export function pickBestMatch(results: readonly SearchResult[], title: string, artist: string | null): SearchResult | null {
818 if (results.length === 0) return null
819 const wantedTitle = normalise(title)
820 if (wantedTitle === '') return null
821 const wantedArtist = artist === null ? null : normalise(artist)
822 const hasArtist = (result: SearchResult) => wantedArtist !== null && normalise(result.artist).includes(wantedArtist)
823 const pick = (list: readonly SearchResult[]) => list.find(hasArtist) ?? (wantedArtist === null ? list[0] : undefined)
824
825 const exact = results.filter(result => normalise(result.title) === wantedTitle)
826 const close = results.filter(result => {
827 const found = normalise(result.title)
828 return found !== wantedTitle && (found.includes(wantedTitle) || wantedTitle.includes(found))
829 })
830 return pick(exact) ?? exact[0] ?? pick(close) ?? null
831}
832
833/** A whole number from 0 to 100; 100 for anything that is not a number. */
834export function clampVolume(volume: number): number {
835 if (!Number.isFinite(volume)) return MAX_VOLUME
836 return Math.min(MAX_VOLUME, Math.max(MIN_VOLUME, Math.round(volume)))
837}
838
839/**
840 * Reads a status script's reply. `off` and `stopped` mean nothing to show;
841 * anything malformed is also nothing, rather than a half-filled row.
842 */
843export function parseStatus(source: PlayerSource, stdout: string, fetchedAt: number): NowPlaying | null {
844 const line = stdout.trim()
845 if (line === '' || line === 'off' || line === 'stopped') return null
846
847 const fields = line.split(FIELD_SEPARATOR)
848 if (fields.length !== STATUS_FIELD_COUNT) return null
849
850 const [
851 stateText = '',
852 title = '',
853 artist = '',
854 album = '',
855 positionText = '',
856 durationText = '',
857 volumeText = '',
858 trackId = '',
859 shuffleText = '',
860 repeatText = '',
861 shareUrlText = '',
862 artworkUrlText = '',
863 ] = fields
864 const state = parsePlaybackState(stateText)
865 if (state === null) return null
866
867 return {
868 source,
869 state,
870 trackId: trackId || `${title}|${artist}|${album}`,
871 title,
872 artist,
873 album,
874 positionSeconds: parseAppleScriptNumber(positionText),
875 durationSeconds: parseAppleScriptNumber(durationText),
876 volume: clampVolume(parseAppleScriptNumber(volumeText)),
877 shuffle: shuffleText === 'true',
878 repeat: parseRepeat(repeatText),
879 shareUrl: shareUrlFor(shareUrlText),
880 artworkUrl: artworkUrlText.startsWith('http') ? artworkUrlText : null,
881 fetchedAt,
882 }
883}
884
885function parsePlaybackState(text: string): PlaybackState | null {
886 if (text === 'playing') return 'playing'
887 if (text === 'paused') return 'paused'
888 return null
889}
890
891function parseRepeat(text: string): RepeatMode {
892 if (text === 'one' || text === 'all') return text
893 if (text === 'true') return 'all'
894 return 'off'
895}
896
897/** A Spotify URI (`spotify:track:ID`) becomes the web link anyone can open. */
898export function shareUrlFor(text: string): string | null {
899 const uri = text.trim()
900 if (uri === '') return null
901 if (uri.startsWith('http')) return uri
902 const parts = uri.split(':')
903 if (parts[0] === 'spotify' && parts.length === 3) {
904 return `https://open.spotify.com/${parts[1]}/${parts[2]}`
905 }
906 return null
907}
908
909/** AppleScript writes reals in the system locale, so a comma may be the point. */
910function parseAppleScriptNumber(text: string): number {
911 const value = Number.parseFloat(text.replace(',', '.'))
912 return Number.isFinite(value) ? value : 0
913}
914
915/**
916 * Which reading the band follows when more than one player is open: whatever
917 * is playing wins; between two in the same state the one shown last stays, so
918 * the band does not flicker between players.
919 */
920export function pickCurrent(
921 readings: readonly (NowPlaying | null)[],
922 previousSource: PlayerSource | null,
923): NowPlaying | null {
924 const present = readings.filter((reading): reading is NowPlaying => reading !== null)
925 if (present.length === 0) return null
926
927 const playing = present.filter(reading => reading.state === 'playing')
928 const pool = playing.length > 0 ? playing : present
929 return pool.find(reading => reading.source === previousSource) ?? pool[0] ?? null
930}
931
932/** Why a script failed: a permission to grant, a player too slow to answer, or something else. */
933export type FailureKind = 'permission' | 'timeout' | 'other'
934
935/**
936 * Reads osascript's stderr. `-1743` is macOS refusing the Apple Event because
937 * the terminal is not allowed to control the player; `-1712` is the event
938 * timing out, which a player still launching, scanning a large library or
939 * waiting on the permission prompt also does.
940 */
941export function classifyFailure(stderr: string): FailureKind {
942 if (/not authori[sz]ed|-1743/i.test(stderr)) return 'permission'
943 if (/timed out|-1712/i.test(stderr)) return 'timeout'
944 return 'other'
945}
946
947/** Turns an osascript failure into one line the band can show. */
948export function describeFailure(source: PlayerSource, stderr: string): string {
949 const app = PLAYERS[source].name
950 switch (classifyFailure(stderr)) {
951 case 'permission':
952 return `${app} is not answering. Allow your terminal to control ${app} under System Settings → Privacy & Security → Automation.`
953 case 'timeout':
954 return `${app} is slow to answer (busy, or waiting on a permission prompt); it will be asked again shortly.`
955 case 'other': {
956 const firstLine = stderr.trim().split('\n')[0] ?? ''
957 return `${app} could not be read${firstLine ? `: ${firstLine}` : '.'}`
958 }
959 }
960}
961types/index.d.ts 192 lines1/**
2 * The now-playing mod's state contract: what the band, footer and queue pane
3 * draw from.
4 */
5
6/** Which music player a snapshot came from. */
7export type PlayerSource = 'music' | 'spotify'
8
9/** Whether the player is playing or paused; a stopped player has no snapshot. */
10export type PlaybackState = 'playing' | 'paused'
11
12/** Repeat as both players spell it; Spotify has no `one`. */
13export type RepeatMode = 'off' | 'one' | 'all'
14
15/** One reading of a player: the track on, where it is, and the player's knobs. */
16export type NowPlaying = {
17 source: PlayerSource
18 state: PlaybackState
19 /** The player's own id for the track, so a change of track is detected. */
20 trackId: string
21 title: string
22 artist: string
23 album: string
24 positionSeconds: number
25 durationSeconds: number
26 /** 0 to 100. */
27 volume: number
28 shuffle: boolean
29 repeat: RepeatMode
30 /** A link to share (Spotify), or null where the player gives none. */
31 shareUrl: string | null
32 /** Where the cover can be downloaded from (Spotify), or null. */
33 artworkUrl: string | null
34 /** `$.clock.now()` when the reading was taken, in milliseconds. */
35 fetchedAt: number
36}
37
38/** The band's view of the world: the track to show, or why there is none. */
39export type PlayerStatus = {
40 current: NowPlaying | null
41 /** A reason the player could not be read (a permission to grant), or null. */
42 failure: string | null
43}
44
45/** A small picture as `0xRRGGBB` pixels, row-major from the top. */
46export type Thumbnail = {
47 width: number
48 height: number
49 pixels: number[]
50}
51
52/** A cover: a PNG on disk for terminals that draw pictures, and a small picture for the rest. */
53export type Artwork = {
54 trackId: string
55 path: string
56 /** Changes with each file written, so a redraw reads the new picture. */
57 generation: number
58 /**
59 * The cover shrunk to a few pixels, folded into half-block cells at whatever
60 * size a site draws it, where no picture can be drawn; null where one can.
61 */
62 thumbnail: Thumbnail | null
63}
64
65export type QueueEntry = {
66 /** The track's position in the current playlist, from 1. */
67 index: number
68 title: string
69 artist: string
70 album: string
71 /** Music's persistent ID, matched against the track on and the covers. */
72 id: string
73}
74
75/** What the queue pane lists: Music's current playlist around the track on. */
76export type Queue = {
77 source: PlayerSource
78 trackId: string
79 /** The playlist position of the track on, so a song listed twice marks the right row. */
80 currentIndex: number
81 playlistName: string
82 /** True when the playlist on is the one this mod adds tracks to. */
83 isModPlaylist: boolean
84 /** Why the list is empty when it is, in a line the pane shows; null with entries. */
85 note: string | null
86 entries: QueueEntry[]
87}
88
89/** One track found: in the Music library (addable) or in the Apple Music catalogue (openable). */
90export type SearchResult = {
91 /** Music's persistent ID for a library track; Apple's track id for a catalogue one. */
92 id: string
93 kind: 'library' | 'catalogue'
94 title: string
95 artist: string
96 album: string
97 /** A `music://` link that opens the track in Music; null for a library track. */
98 url: string | null
99}
100
101/** The last search typed into the pane, and what it found in the library and the catalogue. */
102export type LibrarySearch = {
103 query: string
104 /** Tracks in the library, ready to add. */
105 results: SearchResult[]
106 /** Tracks on Apple Music that the library lacks. */
107 catalogue: SearchResult[]
108 /** A line to show instead of results (nothing found, Music off), or null. */
109 note: string | null
110}
111
112/** One listen, as the history keeps it across sessions. */
113export type HistoryEntry = {
114 id: string
115 title: string
116 artist: string
117 album: string
118 source: PlayerSource
119 /** When the track started, in ms since the epoch. */
120 at: number
121}
122
123/** The lyrics pane's content: the track on, its lyrics, or why there are none. */
124export type LyricsView = {
125 id: string
126 title: string
127 artist: string
128 text: string
129 note: string | null
130}
131
132/** The playlist this mod adds tracks to, as last counted. */
133export type ModPlaylist = {
134 name: string
135 count: number
136 /** The last tracks of the playlist, newest at the end; what the pane lists. */
137 entries: QueueEntry[]
138}
139
140declare module 'claude-code' {
141 /** The tools this mod registers for the model, so their calls are typed. */
142 interface McpToolInputs {
143 'mcp__now-playing__search_library': {
144 query?: string
145 limit?: number
146 scope?: 'both' | 'library' | 'catalogue'
147 genre?: string
148 artist?: string
149 album?: string
150 title?: string
151 yearFrom?: number
152 yearTo?: number
153 minStars?: number
154 favourite?: boolean
155 minPlays?: number
156 maxPlays?: number
157 notPlayedForDays?: number
158 sort?: 'random' | 'leastPlayed' | 'mostPlayed' | 'newest' | 'oldest' | 'topRated' | 'title'
159 }
160 'mcp__now-playing__add_tracks': { tracks?: string[]; ids?: string[]; play?: boolean }
161 'mcp__now-playing__remove_tracks': { ids: string[] }
162 'mcp__now-playing__clear_playlist': Record<string, never>
163 'mcp__now-playing__control_player': {
164 action: 'play' | 'pause' | 'toggle' | 'next' | 'previous' | 'volume' | 'mute' | 'shuffle' | 'repeat' | 'playlist'
165 volume?: number
166 nudge?: 'up' | 'down'
167 }
168 'mcp__now-playing__now_playing': { upNext?: number; lyrics?: boolean }
169 'mcp__now-playing__listening_history': { limit?: number }
170 'mcp__now-playing__open_in_music': { url: string }
171 'mcp__now-playing__show_now_playing': { shown?: boolean }
172 }
173
174 interface PluginState {
175 'now-playing': {
176 status: PlayerStatus
177 isHidden: boolean | null
178 /** Counts the seconds while a track plays, so the clocks move between polls. */
179 tick: number
180 artwork: Artwork | null
181 queue: Queue | null
182 /** Covers of the queue's tracks, by persistent ID, as they arrive. */
183 queueCovers: Record<string, Artwork>
184 search: LibrarySearch | null
185 lyrics: LyricsView | null
186 modPlaylist: ModPlaylist | null
187 /** The volume before a mute, to restore on the next; null while not muted. */
188 mutedVolume: number | null
189 }
190 }
191}
192