SLOPSHOPPER

now-playing

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…

newpanebandspinnerguardcommand
v0.4.0no licenseupdated 2026-10-04benjaminr/nowplaying
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · now-playing
│ ┃ now-playing-queue ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Up next x: close │ now-playing │ │ ┃ Nothing is playing. Play the Claude Code ⏺ Read(src/auth.ts) │ Now Playing is installed and hidden until │ │ ┃ playlist below, or something in Music. ⎿ Read 6 lines │ asked: /np show reveals the band, /np │ │ ┃ ⏺ Update(src/auth.ts) │ queue opens Up next & playlist, or ask │ │ ┃ Add to the Claude Code playlist ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ Added tracks go in the playlist, not Up ⏺ Bash(bun test) │ ┃ next; "play… ⎿ 3 pass, 1 fail │ ┃ Only songs already in your Music library can │ ┃ be add… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Search your library and Apple Music ⏎ search │ ✻ Worked for 42s · done 4:20 PM │ │ › /np │ ⎿ now-playing: Nothing is playing in Music or Spotify. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · now-playing-queue
Up next x: close Nothing is playing. Play the Claude Code playlist below, or something in Music. Add to the Claude Code playlist Added tracks go in the playlist, not Up next; "play… Only songs already in your Music library can be add… Search your library and Apple Music ⏎ search
Pane · now-playing-lyrics
Lyrics x: close Nothing is playing.
README

Now Playing for Claude Code

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" />

The Up next & playlist pane

/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

The lyrics pane

/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.

Features

  • Band above the prompt, framed in the player's colour, with the title and artist on their own rows, a progress bar that moves in eighth-cell steps every second, the clock, volume, and shuffle/repeat glyphs. Collapses to one row while Claude is working.
  • Controls with hotkeys: prev, play/pause, next, volume, mute (restores the old volume), shuffle, repeat (off, all, one), copy a share link, queue, hide, open the player. Transport is bright, the rest dim until the pointer is over the band, and a shuffle or repeat that is on shows bright with its state. The band keeps the most-used controls when it gets narrow.
  • Cover art beside the band (10 by 5 cells) and beside each row of the queue (6 by 3). Terminals that draw pictures (kitty, Ghostty, WezTerm) get the real cover; every other terminal gets a pixel thumbnail drawn from coloured half-block cells.
  • Footer placement as an alternative: the track as a dim label in the prompt footer's bottom-right corner, where focus and similar labels sit.
  • Up next & playlist pane (/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.
  • Search and add: the search box finds tracks in your Music library and adds them to the mod's playlist, since Music's own Up Next cannot be scripted. With "Search Apple Music too" on (the default), the same search also asks Apple's public catalogue, and each track you don't own gets a row that opens it in Music, where it can be played or added to the library.
  • Ask Claude: "add 10 great jazz tracks", "skip this", "what's this song", "play my 90s hip hop I haven't heard in a year" all work. Claude gets nine tools: 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.
  • Lyrics pane (/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.
  • Listening history (/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.
  • Instant updates: a small Swift helper listens for Music's and Spotify's own change notifications, so the band updates the moment a track changes and polls only every 15 seconds to keep the clock honest. It needs Xcode's command line tools; without them the band polls every 2 seconds as before. Off in /config if you prefer.
  • Optional: tell Claude what's playing through a system-prompt section (off by default).

Install

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.

How the pieces fit

  • Now playing is the band: the track your player is on.
  • Up next is what Music will play after it: the rest of whatever playlist or album is playing. The pane shows it and lets you jump to a track, but nothing here can add to it, because Music does not let scripts edit Up Next.
  • The playlist ("Claude Code" by default) is the mod's own, and the only place the pane's search and Claude's 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.
  • Library only. Music lets a script put only tracks you already own into a playlist, so that is all the search box, /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.
  • Spotify is show-and-control only. Spotify's scripting reports the track and takes transport, volume, shuffle and repeat, and nothing else: no queue, no search, no playlists. With Spotify on, the pane's Up next is empty and its add section still adds to the Music playlist.

Commands

/np on its own reports what's on. With an argument:

ArgumentDoes
play, pause, toggle, next, prevTransport
play <name>Music plays the first library track whose name matches
vol <0-100>, vol +, vol -Volume
mute, shuffle, repeatToggle mute, toggle shuffle, cycle repeat
copyCopy the Spotify link, or the track name on Music
openBring the player to the front
queue, upnextOpen or close the Up next & playlist pane
lyricsOpen 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
playlistPlay the mod's playlist
clearEmpty the mod's playlist (the tracks stay in your library; the pane has clear and per-track remove buttons too)
hide, showHide 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.

Settings

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.

Limits

  • Only tracks already in your Music library can be added to the playlist: AppleScript cannot add from the catalogue. Catalogue tracks are found through Apple's public search API (no sign-in) and can only be opened in Music, where you add them to the library yourself.
  • What each player's scripting allows:
Apple MusicSpotify
Band: track, progress, cover artyesyes
Play, pause, skip, volume, mute, shuffleyesyes
Repeatoff, all, oneoff, all
Copythe track namea share link
Up next paneyesno queue in its scripting
Play by name, searchlibrary onlyno
Add to the playlist, play the playlist, clear itlibrary tracks onlyno
Open a link in the appmusic.apple.com linksno
Lyricsstored with library tracks onlyno
Filter by genre, year, stars, favourite, play countyes (favourite needs macOS 14 or later)no
Listening historyyesyes
Instant updatesyesyes
  • Full-resolution cover art needs a terminal that speaks the kitty graphics protocol. iTerm2 and Terminal.app do not, so they get the 4×2 cell thumbnail instead.

Development

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.

Source 5 files
hooks/register.tsx 2316 lines
1/**
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 lines
1/**
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}
178
hooks/pixels.ts 125 lines
1/**
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}
125
hooks/players.ts 961 lines
1/**
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}
961
types/index.d.ts 192 lines
1/**
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