SLOPSHOPPER

music-pane

A music player beside the chat: your albums and YouTube, a search by title, singer or a line of the lyrics, similar songs after each one, the cover, a progress…

newpaneguardcommandtoolprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · music-pane
│ ┃ Music ✕ › fix the failing auth test and add an audit log call │ ┃ Music │ ┃ Search, listen, and similar songs keep ⏺ Read(src/auth.ts) │ ┃ coming ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Search: A song, a singer, a line of the lyri ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ TRY ⎿ 3 pass, 1 fail │ ┃ [ Romantic salsa ] [ Rock classics ] [ Lo-fi │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Theme theme:pink theme:red theme:orange them │ ✻ Worked for 42s · done 4:20 PM │ │ › /music │ ⎿ music-pane: Music player opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Music
Music Search, listen, and similar songs keep coming Search: A song, a singer, a line of the lyrics, or a YouTube TRY [ Romantic salsa ] [ Rock classics ] [ Lo-fi to focus ] [ 80 Theme theme:pink theme:red theme:orange theme:yellow theme:g
README

music-pane

A music player in a pane beside the chat. It plays your albums and YouTube with mpv, searches by title, singer or a line of the lyrics, keeps going with similar songs, and shows the cover, a progress bar with the time, and the lyric line being sung, kept in step with the position mpv reports.

Install

At the prompt of a terminal session:

/plugin install music-pane --marketplace DiegoFernandoLojanTenesaca/indaga-mods

From a shell:

claude plugin install music-pane --marketplace DiegoFernandoLojanTenesaca/indaga-mods

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with DiegoFernandoLojanTenesaca/indaga-mods, then Add plugin.

It needs mpv to play, ffmpeg from FFmpeg for the covers, and yt-dlp for YouTube. The mod hands mpv a small Lua script of its own (player/control.lua) that reads a control file the mod writes, one per chat, so volume, pause and a jump in the song change while it plays, with no gap and nothing heard twice, and a chat never steers the player of another.

On Windows:

winget install mpv-player.mpv-CI.MSVC
winget install yt-dlp.yt-dlp
winget install Gyan.FFmpeg

A program installed while Claude Code (or the app that runs it) was open is found once that app starts again; or set its full path in the player, ytdlp and ffmpeg options. Keep yt-dlp current (winget upgrade yt-dlp.yt-dlp): YouTube changes often, and an old yt-dlp stops reading it.

mpv plays on the computer where Claude Code runs. From an app that reaches that computer from elsewhere (CConnect on a phone, say), the sound comes out of that computer.

Options

OptionWhat it is
libraryThe music folder, with one subfolder per album.
channelA YouTube channel (@name or its URL) whose playlists show as albums.
artistThe artist of the music folder: searched with its songs, and taken off the front of YouTube titles.
player, ffmpeg, ytdlpThe executables, by name on the PATH or by full path.
openOnStartOpen the pane when a session starts. On by default.

Opening it

The pane opens when a session starts. Closed, it opens again with /music in a terminal session, or by asking Claude for music wherever /music does not run (the desktop app and other hosts).

Searching

The search field takes a title, a singer, a line of the lyrics or a YouTube link. It looks through the music folder (titles, albums, the artist and every line of the lyrics) and YouTube, and lists both. When nothing in the folder matches but a title or a line comes close, it asks "Did you mean ...?", and that line plays the song. A link adds the video or playlist as an album.

Asked for music in the chat ("play some romantic salsa", "put on the song that goes ..."), Claude searches the same way through the mod's play tool and plays the best match, which also works where the pane has no text field.

Similar songs

With "Similar songs next" on, which is the default, the radio goes on when an album ends, and right after a song picked from a search: it plays YouTube's mix of that song, the songs YouTube lists as like it, by the same singer or others, skipping what it already played and other uploads of the same song (a live take, a lyric video). For a song from the music folder it starts from the song's YouTube video, found by its title and the artist. Off, it stops at the end of the album.

The music folder

library/
├── First Album/
│   ├── Cover.png
│   ├── Songs/01 - Opening.mp3
│   ├── Songs/01 - Opening.txt
│   └── Videos/01 - Opening.srt
└── Second Album/
    └── 01 - Another.mp3

Each subfolder of library is an album. A song is an audio file (.mp3, .flac, .ogg, .opus, .m4a, .wav) in the album folder or one level below it. Its lyrics are the .srt of the same name, shown line by line as they are sung, or else the .txt of the same name, shown whole. An image in the album folder is its cover, one named cover, folder, front or portada first. Folders whose name starts with a dot are skipped.

YouTube

The playlists of channel show as albums next to the local ones. A video plays its audio, with its uploaded subtitles as the synced lyrics and its thumbnail as the cover. Starting a YouTube song takes a few seconds while yt-dlp reads it. A song of up to 10 MB (about ten minutes) is asked of YouTube whole, in one piece: it arrives in under a second and the network stays quiet while it plays, which helps Bluetooth headphones that share their radio with a 2.4 GHz Wi-Fi. A longer one streams as it plays.

The pane

Before anything plays it greets with a banner in the theme color, a few searches to start with, and the songs played lately (kept between sessions).

Once something plays, from the top: the search field, then the results of the last search (closed with Close, or by picking a song), then what is playing: a wide cover, the title, the singer and the album, a progress bar with the time on each side, the controls in the middle with the volume beside them, and "Similar songs next" under them; the YouTube link sits at the right of the title. Below come the lyrics, the next five songs, and the library (the music folder, the channel's playlists and pasted links), which shows only when there is one. Every option may stay empty: the search works alone.

Using it

Search, or pick an album in the library, then a song. ❚❚ pauses and ⏵ goes on from the same instant. Drag or click the progress bar to move through the song, as on YouTube: the player jumps there when you let go, without starting again, and a paused song goes on from there (the phone and the editor show the bar without moving it). Drag or click the volume bar too; where the surface has no such bar (the phone, the editor), − and + move it by 10. The color dots at the bottom of the pane pick the theme (pink, red, orange, yellow, green, cyan, blue or purple), which colors the play button, the bars, the banner, the line being sung and the song playing. Volume and theme are kept for the next session.

A YouTube song is read over a connection YouTube sometimes drops; mpv reconnects, and a song that still ends more than five seconds early starts again where it stopped, twice at most, instead of skipping to the next one. A YouTube song that never starts is asked of YouTube again, twice at most, before the pane gives up on it.

When it does not play

The pane says why, in mpv's own words where it has them:

  • Could not run mpv: mpv is not installed, or not where the player option says. The same message names yt-dlp when a search or a YouTube song cannot run it.
  • mpv could not play a song, and the reason: mpv started and gave up on that song, as with HTTP error 403 Forbidden from YouTube. Update yt-dlp first when YouTube songs stop playing.

The small covers are made once with ffmpeg and kept in the system's temporary folder. Nothing else leaves your computer but what YouTube is asked for.

Source 11 files
hooks/register.tsx 959 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5  MusicAlbum,
6  MusicBusy,
7  MusicCover,
8  MusicCue,
9  MusicLyrics,
10  MusicPlayback,
11  MusicProblem,
12  MusicSuggestion,
13  MusicTrack,
14} from '../types'
15import { coverFilterOf, coverSvgOf, progressSvgOf, progressTextOf, welcomeSvgOf } from './art'
16import { ICONS } from './icons'
17import { LAYOUT } from './layout'
18import {
19  clockOf,
20  complaintOf,
21  cueIndexAt,
22  hashOf,
23  joinPath,
24  nameOf,
25  parseSrt,
26  reportOf,
27  scanLibrary,
28  splitTitle,
29  stanzasOf,
30  youtubeUrlOf,
31} from './library'
32import { QUICK_PICKS, localeOf, translate } from './locales'
33import type { Locale, MessageKey } from './locales'
34import { DEFAULT_THEME, PALETTE, THEMES, isTheme } from './palette'
35import type { ThemeName } from './palette'
36import { searchLocal, songKeyOf } from './search'
37import type { Searchable } from './search'
38import {
39  channelAlbumsOf,
40  durationMsOf,
41  isYoutubeLink,
42  linkAlbumOf,
43  listingAlbumOf,
44  mixUrlOf,
45  playlistsUrlOf,
46  searchOf,
47  subtitleUrlOf,
48  videoIdOf,
49  wholeAudioUrlOf,
50  youtubeTracksOf,
51} from './youtube'
52import type { YoutubeListing, YoutubeVideo } from './youtube'
53
54const PANE = 'music-pane'
55const COMMAND = 'music'
56const OPEN_TOOL = 'open'
57const OPEN_CALLED = 'mcp__music-pane__open'
58const OPEN_DESCRIPTION =
59  'Opens the music player pane beside the chat, where the person searches, picks and plays songs. Call it when they ask to see the music player.'
60const PLAY_TOOL = 'play'
61const PLAY_CALLED = 'mcp__music-pane__play'
62const PLAY_DESCRIPTION =
63  'Plays music in the player beside the chat: searches the local library (titles, singers and lyrics) and YouTube for the query and plays the best match; similar songs follow it. Call it when the person asks to play or put on a song, a singer, a genre, a mood or a line of lyrics.'
64const PLAY_SCHEMA = {
65  type: 'object',
66  properties: {
67    query: {
68      type: 'string',
69      description: 'What to play: a title, a singer, a genre or mood, a line of the lyrics, or a YouTube link.',
70    },
71  },
72  required: ['query'],
73}
74const DEFAULTS = { player: 'mpv', ffmpeg: 'ffmpeg', ytdlp: 'yt-dlp' }
75const YTDLP_TIMEOUT_MS = 90_000
76const FFMPEG_TIMEOUT_MS = 30_000
77const PLAYED_LIMIT = 200
78const RADIO_SIZE = 25
79const END_SLACK_MS = 5_000
80const MAX_RETRIES = 2
81const HEARD_LIMIT = 4_000
82const VOLUME = { step: 10, least: 0, most: 100, initial: 100 }
83const RECONNECT = '--stream-lavf-o=reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5'
84const PLAYER_SCRIPT = ['player', 'control.lua']
85const CONTROL_FILE = { prefix: 'music-pane-control-', suffix: '.json' }
86const UNSAFE_NAME = /[^\w-]/g
87const SLIDER = { volume: 'volume', progress: 'progress' }
88const MS_PER_SECOND = 1000
89const STORE = { theme: 'theme', volume: 'volume', history: 'history' }
90const HISTORY = 'history'
91const COVER_PREFIX = `music-pane-cover-${LAYOUT.coverWidth}x${LAYOUT.coverHeight}-`
92const BROWSABLE = new Set<MusicAlbum['source']>(['local', 'channel', 'link'])
93const KEPT = new Set<MusicAlbum['source']>(['link', 'search', 'radio', 'history'])
94const ONE_OFF = new Set<MusicAlbum['source']>(['search', 'history'])
95
96const albums = atom({ plugin: 'music-pane', key: 'albums' } as const, [] as MusicAlbum[])
97const album = atom({ plugin: 'music-pane', key: 'album' } as const, '')
98const playback = atom({ plugin: 'music-pane', key: 'playback' } as const, null as MusicPlayback | null)
99const lyrics = atom({ plugin: 'music-pane', key: 'lyrics' } as const, null as MusicLyrics | null)
100const cover = atom({ plugin: 'music-pane', key: 'cover' } as const, null as MusicCover | null)
101const busy = atom({ plugin: 'music-pane', key: 'busy' } as const, null as MusicBusy | null)
102const problem = atom({ plugin: 'music-pane', key: 'problem' } as const, null as MusicProblem | null)
103const suggestion = atom({ plugin: 'music-pane', key: 'suggestion' } as const, null as MusicSuggestion | null)
104const results = atom({ plugin: 'music-pane', key: 'results' } as const, null as string | null)
105const isRadio = atom({ plugin: 'music-pane', key: 'isRadio' } as const, true)
106const played = atom({ plugin: 'music-pane', key: 'played' } as const, [] as string[])
107const theme = atom({ plugin: 'music-pane', key: 'theme' } as const, DEFAULT_THEME as string)
108const volume = atom({ plugin: 'music-pane', key: 'volume' } as const, VOLUME.initial)
109const history = atom({ plugin: 'music-pane', key: 'history' } as const, [] as MusicTrack[])
110
111type Config = { library: string; artist: string; channel: string; player: string; ffmpeg: string; ytdlp: string }
112
113type Ready = { source: string; sung: MusicLyrics; durationMs?: number }
114
115let generation = 0
116let live: number | undefined
117let seeks = 0
118let wantedCover: string | undefined
119let locale: Promise<Locale> | undefined
120const lineCache = new Map<string, string[]>()
121
122const textOption = (value: unknown, fallback = '') => (typeof value === 'string' && value.trim() ? value.trim() : fallback)
123
124const isBrowsable = (one: MusicAlbum) => BROWSABLE.has(one.source)
125
126const isStream = (source: string) => /^https?:\/\//.test(source)
127
128const volumeOf = (value: unknown) =>
129  typeof value === 'number' && Number.isFinite(value) ? Math.min(VOLUME.most, Math.max(VOLUME.least, Math.round(value))) : undefined
130
131export const configOf = (options: Record<string, unknown>): Config => ({
132  library: textOption(options.library),
133  artist: textOption(options.artist),
134  channel: textOption(options.channel),
135  player: textOption(options.player, DEFAULTS.player),
136  ffmpeg: textOption(options.ffmpeg, DEFAULTS.ffmpeg),
137  ytdlp: textOption(options.ytdlp, DEFAULTS.ytdlp),
138})
139
140export type PlayerRun = { source: string; fromMs: number; level: number; script: string; control: string; run: number }
141
142export const playerArgs = ({ source, fromMs, level, script, control, run }: PlayerRun) => [
143  '--no-video',
144  '--no-config',
145  '--quiet',
146  '--msg-level=all=warn',
147  '--idle=no',
148  ...(isStream(source) ? [RECONNECT] : []),
149  `--volume=${level}`,
150  `--start=${(fromMs / 1000).toFixed(3)}`,
151  `--script=${script}`,
152  `--script-opts=music-pane-control=${control},music-pane-run=${run}`,
153  source,
154]
155
156function localeFor($: EngineInterface) {
157  locale ??= $.settings.read({ key: 'language' }).then(
158    language => localeOf(typeof language === 'string' ? language : undefined),
159    () => localeOf(undefined),
160  )
161  return locale
162}
163
164async function openPane($: EngineInterface) {
165  const current = await localeFor($)
166  await $.ui.open({ id: PANE, title: translate(current, 'title') })
167  return translate(current, 'commandOpened')
168}
169
170async function runJson<T>($: EngineInterface, argv: string[]): Promise<T> {
171  const out = await $.process.run(argv, { timeoutMs: YTDLP_TIMEOUT_MS }).catch(async (error: unknown) => {
172    await setProblem($, { key: 'toolUnavailable', values: { tool: nameOf(argv[0] ?? '') } })
173    throw error
174  })
175  if (out.exitCode !== 0) throw new Error(out.stderr)
176  return JSON.parse(out.stdout) as T
177}
178
179async function setProblem($: EngineInterface, next: MusicProblem | null) {
180  await update($, problem, () => next)
181}
182
183async function raiseProblem($: EngineInterface, next: MusicProblem) {
184  await update($, problem, current => (current?.key === 'toolUnavailable' ? current : next))
185}
186
187async function setBusy($: EngineInterface, next: MusicBusy | null) {
188  await update($, busy, () => next)
189}
190
191async function loadMemory($: EngineInterface) {
192  const [savedTheme, savedVolume, savedHistory] = await Promise.all([
193    $.store.get(STORE.theme),
194    $.store.get(STORE.volume),
195    $.store.get(STORE.history),
196  ])
197  if (isTheme(savedTheme)) await update($, theme, () => savedTheme)
198  const level = volumeOf(savedVolume)
199  if (level !== undefined) await update($, volume, () => level)
200  if (Array.isArray(savedHistory)) await update($, history, () => savedHistory as MusicTrack[])
201}
202
203async function loadShelf($: EngineInterface, config: Config) {
204  await setProblem($, null)
205  const [local, channel] = await Promise.all([
206    config.library ? scanLibrary($, config.library).catch(() => undefined) : [],
207    config.channel
208      ? runJson<YoutubeListing>($, [config.ytdlp, '--flat-playlist', '-J', playlistsUrlOf(config.channel)]).then(
209          channelAlbumsOf,
210          () => [],
211        )
212      : [],
213  ])
214  await update($, albums, current => [
215    ...(local ?? []),
216    ...channel.map(one => current.find(known => known.id === one.id && known.isLoaded) ?? one),
217    ...current.filter(known => KEPT.has(known.source)),
218  ])
219  const shelf = (await read($, albums)).filter(isBrowsable)
220  await update($, album, current => (shelf.some(one => one.id === current) ? current : (shelf[0]?.id ?? '')))
221  if (local === undefined) await raiseProblem($, { key: 'libraryMissing', values: { path: config.library } })
222  await loadAlbum($, config, await read($, album))
223}
224
225async function loadAlbum($: EngineInterface, config: Config, albumId: string) {
226  const target = (await read($, albums)).find(one => one.id === albumId)
227  if (!target?.url || target.isLoaded) return
228  await setBusy($, 'album')
229  const listing = await runJson<YoutubeListing>($, [config.ytdlp, '--flat-playlist', '-J', target.url]).catch(() => undefined)
230  await setBusy($, null)
231  if (!listing) return raiseProblem($, { key: 'youtubeFailed', values: { url: target.url } })
232  const tracks = youtubeTracksOf(albumId, listing, config.artist)
233  await update($, albums, current => current.map(one => (one.id === albumId ? { ...one, tracks, isLoaded: true } : one)))
234}
235
236async function chooseAlbum($: EngineInterface, config: Config, albumId: string) {
237  await update($, album, () => albumId)
238  await loadAlbum($, config, albumId)
239}
240
241async function shelve($: EngineInterface, added: MusicAlbum, replaces: (one: MusicAlbum) => boolean) {
242  await update($, albums, current => [...current.filter(one => one.id !== added.id && !replaces(one)), added])
243}
244
245async function addLink($: EngineInterface, config: Config, link: string) {
246  await setBusy($, 'search')
247  const listing = await runJson<YoutubeListing>($, [config.ytdlp, '--flat-playlist', '-J', link]).catch(() => undefined)
248  await setBusy($, null)
249  if (!listing) {
250    await raiseProblem($, { key: 'youtubeFailed', values: { url: link } })
251    return undefined
252  }
253  const added = linkAlbumOf(link, listing, config.artist)
254  await shelve($, added, () => false)
255  await update($, album, () => added.id)
256  await update($, results, () => added.id)
257  return added.tracks[0]
258}
259
260async function readText($: EngineInterface, path: string | undefined) {
261  return path ? $.fs.read(path).catch(() => '') : ''
262}
263
264async function linesOf($: EngineInterface, track: MusicTrack) {
265  const path = track.synced ?? track.plain
266  if (!path) return []
267  const known = lineCache.get(path)
268  if (known) return known
269  const text = await readText($, path)
270  const lines = track.synced ? parseSrt(text).map(cue => cue.text) : stanzasOf(text).flatMap(stanza => stanza.split('\n'))
271  lineCache.set(path, lines)
272  return lines
273}
274
275async function closeResults($: EngineInterface) {
276  await update($, results, () => null)
277  await update($, suggestion, () => null)
278}
279
280async function search($: EngineInterface, config: Config, query: string): Promise<MusicTrack | undefined> {
281  const wanted = query.trim()
282  if (!wanted) return undefined
283  await closeResults($)
284  await setProblem($, null)
285  if (isYoutubeLink(wanted)) return addLink($, config, wanted)
286  await setBusy($, 'search')
287  const shelf = await read($, albums)
288  const items: Searchable[] = await Promise.all(
289    shelf
290      .filter(one => one.source === 'local')
291      .flatMap(one => one.tracks.map(async track => ({ track, album: one.name, artist: config.artist, lines: await linesOf($, track) }))),
292  )
293  const local = searchLocal(wanted, items)
294  const online = await runJson<YoutubeListing>($, [config.ytdlp, '--flat-playlist', '-J', searchOf(wanted)]).catch(() => undefined)
295  await setBusy($, null)
296  await update($, suggestion, () => local.suggestion ?? null)
297  const id = `search:${wanted}`
298  const homeOf = (track: MusicTrack) => shelf.find(one => one.id === track.album)?.name
299  const tracks = [
300    ...local.hits.map(track => ({ ...track, album: id, byline: homeOf(track) })),
301    ...(online ? youtubeTracksOf(id, online, config.artist) : []),
302  ]
303  if (tracks.length === 0) {
304    await raiseProblem($, { key: 'nothingFound', values: { query: wanted } })
305    return local.suggestion?.track
306  }
307  await shelve($, listingAlbumOf(id, wanted, 'search', tracks), one => one.source === 'search')
308  await update($, results, () => id)
309  return tracks[0]
310}
311
312async function seedOf($: EngineInterface, config: Config, track: MusicTrack) {
313  const own = track.page ? videoIdOf(track.page) : undefined
314  if (own) return own
315  const query = [config.artist, splitTitle(track.title).name].filter(Boolean).join(' ')
316  const found = await runJson<YoutubeListing>($, [config.ytdlp, '--flat-playlist', '-J', searchOf(query, 1)]).catch(() => undefined)
317  return found?.entries?.[0]?.id
318}
319
320async function startRadio($: EngineInterface, config: Config, track: MusicTrack) {
321  await setBusy($, 'radio')
322  const seed = await seedOf($, config, track)
323  const listing = seed
324    ? await runJson<YoutubeListing>($, [config.ytdlp, '--flat-playlist', '--playlist-end', String(RADIO_SIZE), '-J', mixUrlOf(seed)]).catch(
325        () => undefined,
326      )
327    : undefined
328  await setBusy($, null)
329  if (!seed || !listing) return false
330  const seen = new Set([...(await read($, played)), songKeyOf(track.title)])
331  const id = `radio:${seed}`
332  const tracks = youtubeTracksOf(id, listing, config.artist).filter(one => {
333    const key = songKeyOf(one.title)
334    if (seen.has(key) || videoIdOf(one.id) === seed) return false
335    seen.add(key)
336    return true
337  })
338  if (tracks.length === 0) return false
339  await shelve($, listingAlbumOf(id, splitTitle(track.title).name, 'radio', tracks), one => one.source === 'radio')
340  await play($, config, tracks[0]!)
341  return true
342}
343
344async function prepareLocal($: EngineInterface, track: MusicTrack, audio: string): Promise<Ready> {
345  const [synced, plain] = await Promise.all([readText($, track.synced), readText($, track.plain)])
346  return { source: audio, sung: { trackId: track.id, cues: parseSrt(synced), stanzas: stanzasOf(plain) } }
347}
348
349async function prepareVideo($: EngineInterface, config: Config, track: MusicTrack, page: string): Promise<Ready> {
350  const video = await runJson<YoutubeVideo>($, [config.ytdlp, '-f', 'bestaudio', '--no-playlist', '-J', page])
351  if (!video.url) throw new Error(page)
352  const subtitle = subtitleUrlOf(video, await localeFor($))
353  const synced = subtitle ? await $.http.fetch(subtitle).then(answer => (answer.ok ? answer.text : ''), () => '') : ''
354  return {
355    source: wholeAudioUrlOf(video.url, video.filesize),
356    sung: { trackId: track.id, cues: parseSrt(synced), stanzas: [] },
357    durationMs: durationMsOf(video.duration),
358  }
359}
360
361async function prepare($: EngineInterface, config: Config, track: MusicTrack) {
362  if (track.audio) return prepareLocal($, track, track.audio)
363  if (track.page) return prepareVideo($, config, track, track.page)
364  throw new Error(track.id)
365}
366
367async function tempFolderOf($: EngineInterface) {
368  return (await $.env.get('TEMP')) ?? (await $.env.get('TMPDIR')) ?? '/tmp'
369}
370
371async function loadCover($: EngineInterface, config: Config, source: string | undefined) {
372  wantedCover = source
373  if (!source || (await read($, cover))?.source === source) return
374  const target = joinPath(await tempFolderOf($), `${COVER_PREFIX}${hashOf(source)}.jpg`)
375  if (!(await $.fs.exists(target))) {
376    const filter = coverFilterOf(LAYOUT.coverWidth, LAYOUT.coverHeight)
377    const argv = [config.ffmpeg, '-hide_banner', '-loglevel', 'error', '-y', '-i', source, '-vf', filter, '-frames:v', '1', target]
378    const made = await $.process.run(argv, { timeoutMs: FFMPEG_TIMEOUT_MS })
379    if (made.exitCode !== 0) return
380  }
381  const { base64 } = await $.fs.read(target, { as: 'bytes' })
382  if (wantedCover === source) await update($, cover, () => ({ source, data: base64 }))
383}
384
385async function remember($: EngineInterface, track: MusicTrack) {
386  const key = songKeyOf(track.title)
387  const kept = (await read($, history)).filter(one => songKeyOf(one.title) !== key)
388  const next = [{ ...track, album: HISTORY }, ...kept].slice(0, LAYOUT.recentSize)
389  await update($, history, () => next)
390  await $.store.set(STORE.history, next)
391}
392
393async function fail($: EngineInterface, run: number, reason: MusicProblem) {
394  if (run !== generation) return
395  await update($, playback, () => null)
396  await raiseProblem($, reason)
397}
398
399async function stop($: EngineInterface) {
400  generation++
401  await update($, playback, () => null)
402}
403
404async function controlPathOf($: EngineInterface) {
405  const session = (await $.session.id()).replace(UNSAFE_NAME, '_')
406  return joinPath(await tempFolderOf($), `${CONTROL_FILE.prefix}${session}${CONTROL_FILE.suffix}`)
407}
408
409async function steer($: EngineInterface, run: number, isPaused: boolean, seekMs?: number) {
410  const seek = seekMs === undefined ? {} : { seek: seekMs / MS_PER_SECOND, seekId: ++seeks }
411  await $.fs.write(await controlPathOf($), JSON.stringify({ run, volume: await read($, volume), pause: isPaused, ...seek }))
412}
413
414async function listen(
415  $: EngineInterface,
416  config: Config,
417  track: MusicTrack,
418  ready: Ready,
419  fromMs: number,
420  run: number,
421  attempt: number,
422) {
423  const cues: MusicCue[] = ready.sung.cues
424  const control = await controlPathOf($)
425  const level = await read($, volume)
426  const script = PLAYER_SCRIPT.reduce(joinPath, $.plugin.root)
427  await steer($, run, false)
428  let shown = ''
429  let lastMs = fromMs
430  let lengthMs = ready.durationMs
431  let hasPlayed = false
432  let hasStarted = false
433  let heard = ''
434  const argv = [config.player, ...playerArgs({ source: ready.source, fromMs, level, script, control, run })]
435  live = run
436  try {
437    for await (const piece of $.process.spawn({ argv })) {
438      hasStarted = true
439      if (run !== generation) return
440      if (!hasPlayed && heard.length < HEARD_LIMIT) heard += piece.text
441      const report = reportOf(piece.text)
442      const durationMs = report.lengthMs
443      if (durationMs !== undefined && durationMs !== lengthMs) {
444        lengthMs = durationMs
445        await update($, playback, current => (current?.trackId === track.id ? { ...current, durationMs } : current))
446      }
447      const positionMs = report.positionMs
448      if (positionMs === undefined) continue
449      hasPlayed = true
450      lastMs = positionMs
451      const mark = `${Math.floor(positionMs / 1000)}:${cueIndexAt(cues, positionMs)}`
452      if (mark === shown) continue
453      shown = mark
454      await update($, playback, current => (current?.trackId === track.id ? { ...current, positionMs } : current))
455    }
456  } catch (error) {
457    if (hasStarted) throw error
458    return fail($, run, { key: 'toolUnavailable', values: { tool: nameOf(config.player) } })
459  } finally {
460    if (live === run) live = undefined
461  }
462  if (run !== generation) return
463  const player = nameOf(config.player)
464  if (!hasPlayed && isStream(ready.source) && attempt < MAX_RETRIES) return play($, config, track, fromMs, undefined, attempt + 1)
465  const reason = complaintOf(heard)
466  if (!hasPlayed && reason) return fail($, run, { key: 'playerFailedReason', values: { player, title: track.title, reason } })
467  if (!hasPlayed) return fail($, run, { key: 'playerFailed', values: { player, title: track.title } })
468  const isCut = lengthMs !== undefined && lastMs < lengthMs - END_SLACK_MS
469  if (isCut && attempt < MAX_RETRIES) return play($, config, track, lastMs, undefined, attempt + 1)
470  await skipFrom($, config, track, 1)
471}
472
473async function play($: EngineInterface, config: Config, track: MusicTrack, fromMs = 0, known?: string, attempt = 0) {
474  const run = ++generation
475  await setProblem($, null)
476  await update($, playback, current => ({
477    albumId: track.album,
478    trackId: track.id,
479    positionMs: fromMs,
480    durationMs: current?.trackId === track.id ? current.durationMs : track.durationMs,
481    source: known,
482    isPlaying: true,
483    isLoading: true,
484  }))
485  const key = songKeyOf(track.title)
486  await update($, played, list => [...list.filter(one => one !== key), key].slice(-PLAYED_LIMIT))
487  void loadCover($, config, track.cover).catch(() => {})
488  const cached = await read($, lyrics)
489  const ready =
490    known && cached?.trackId === track.id
491      ? { source: known, sung: cached }
492      : await prepare($, config, track).catch(() => undefined)
493  if (run !== generation) return
494  if (!ready) return fail($, run, { key: 'youtubeFailed', values: { url: track.page ?? track.id } })
495  if (ready.sung !== cached) await update($, lyrics, () => ready.sung)
496  await update($, playback, current =>
497    current?.trackId === track.id
498      ? { ...current, source: ready.source, durationMs: current.durationMs ?? ready.durationMs, isLoading: false }
499      : current,
500  )
501  if (!known && attempt === 0) void remember($, track).catch(() => {})
502  void listen($, config, track, ready, fromMs, run, attempt)
503    .catch(() => fail($, run, { key: 'playerFailed', values: { player: config.player, title: track.title } }))
504    .catch(() => {})
505}
506
507async function pick($: EngineInterface, config: Config, track: MusicTrack) {
508  await closeResults($)
509  await play($, config, track)
510}
511
512async function playRecent($: EngineInterface, config: Config, track: MusicTrack) {
513  const recent = await read($, history)
514  await shelve($, listingAlbumOf(HISTORY, '', 'history', recent), one => one.source === 'history')
515  await pick($, config, { ...track, album: HISTORY })
516}
517
518async function currentTrack($: EngineInterface) {
519  const current = await read($, playback)
520  const home = current ? (await read($, albums)).find(one => one.id === current.albumId) : undefined
521  const track = home?.tracks.find(one => one.id === current?.trackId)
522  return current && track ? { current, track } : undefined
523}
524
525async function skipFrom($: EngineInterface, config: Config, track: MusicTrack, step: number) {
526  const home = (await read($, albums)).find(one => one.id === track.album)
527  const follows = await read($, isRadio)
528  const isOneOff = home !== undefined && ONE_OFF.has(home.source) && follows && step > 0
529  const list = isOneOff ? [] : (home?.tracks ?? [])
530  const target = list[Math.max(0, list.findIndex(one => one.id === track.id) + step)]
531  if (target) return play($, config, target)
532  if (step > 0 && follows && (await startRadio($, config, track))) return
533  await stop($)
534}
535
536async function skip($: EngineInterface, config: Config, step: number) {
537  const now = await currentTrack($)
538  if (now) await skipFrom($, config, now.track, step)
539}
540
541async function toggle($: EngineInterface, config: Config) {
542  const now = await currentTrack($)
543  if (!now || now.current.isLoading) return
544  if (live === generation) {
545    const isPaused = now.current.isPlaying
546    await steer($, generation, isPaused)
547    await update($, playback, current => current && { ...current, isPlaying: !isPaused })
548    return
549  }
550  if (!now.current.isPlaying) await play($, config, now.track, now.current.positionMs, now.current.source)
551}
552
553async function setVolume($: EngineInterface, wanted: number, isKept: boolean) {
554  const level = volumeOf(wanted) ?? VOLUME.initial
555  if (level !== (await read($, volume))) {
556    await update($, volume, () => level)
557    const current = await read($, playback)
558    if (live === generation && current) await steer($, generation, !current.isPlaying)
559  }
560  if (isKept) await $.store.set(STORE.volume, level)
561}
562
563async function seekTo($: EngineInterface, positionMs: number) {
564  const current = await read($, playback)
565  if (!current || current.isLoading) return
566  await update($, playback, now => now && { ...now, positionMs })
567  if (live === generation) await steer($, generation, !current.isPlaying, positionMs)
568}
569
570async function changeVolume($: EngineInterface, step: number) {
571  await setVolume($, (await read($, volume)) + step, true)
572}
573
574async function chooseTheme($: EngineInterface, name: string) {
575  if (!isTheme(name)) return
576  await update($, theme, () => name)
577  await $.store.set(STORE.theme, name)
578}
579
580async function toggleRadio($: EngineInterface) {
581  await update($, isRadio, value => !value)
582}
583
584export const register: Register = (on, options) => {
585  const config = configOf(options)
586  const openOnStart = options.openOnStart !== false
587  locale = undefined
588
589  on('session.start', async ($, e, next) => {
590    const started = await next(e)
591    await update($, playback, current => (current?.albumId ? { ...current, isPlaying: false, isLoading: false } : null))
592    await setBusy($, null)
593    await update($, suggestion, () => null)
594    const current = await localeFor($)
595    await $.command.register({ name: COMMAND, description: translate(current, 'commandDescription') }).catch(() => {})
596    await $.tool.register({ name: OPEN_TOOL, description: OPEN_DESCRIPTION }).catch(() => {})
597    await $.tool.register({ name: PLAY_TOOL, description: PLAY_DESCRIPTION, inputSchema: PLAY_SCHEMA }).catch(() => {})
598    await loadMemory($).catch(() => {})
599    void loadShelf($, config).catch(() => {})
600    if (openOnStart) void openPane($).catch(() => {})
601    return started
602  })
603
604  on('session.end', async ($, e, next) => {
605    generation++
606    return next(e)
607  })
608
609  on('command.run', { command: COMMAND }, async $ => {
610    void loadShelf($, config).catch(() => {})
611    return { text: await openPane($) }
612  })
613
614  on('tool.call', { tool: OPEN_CALLED }, async $ => {
615    void loadShelf($, config).catch(() => {})
616    return { result: await openPane($) }
617  })
618
619  on('tool.call', { tool: PLAY_CALLED }, async ($, e) => {
620    void openPane($).catch(() => {})
621    const current = await localeFor($)
622    const chosen = await search($, config, e.query)
623    if (!chosen) return { result: translate(current, 'nothingFound', { query: e.query }) }
624    void pick($, config, chosen).catch(() => {})
625    return { result: translate(current, 'toolPlaying', { title: splitTitle(chosen.title).name }) }
626  })
627
628  on('ui.message', { component: 'Pane', requestId: PANE }, async ($, e) => {
629    const sent = e.data as { level?: unknown; isDone?: unknown } | null
630    if (typeof sent?.level !== 'number') return {}
631    const isDone = sent.isDone === true
632    if (e.element === SLIDER.volume) await setVolume($, sent.level, isDone)
633    if (e.element === SLIDER.progress && isDone) await seekTo($, sent.level * MS_PER_SECOND)
634    return {}
635  })
636
637  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
638    const { Box, Button, Link, Text } = $.ui.resolve(e)
639    const current = await localeFor($)
640    const t = (key: MessageKey, values?: Record<string, string>) => translate(current, key, values)
641    const heading = (key: MessageKey, values?: Record<string, string>) => (
642      <Text bold dimColor>
643        {t(key, values).toLocaleUpperCase(current)}
644      </Text>
645    )
646    const [all, chosen, now, sung, art, loading, issue, offer, found, follows, painted, level, recent] = await Promise.all([
647      read($, albums),
648      read($, album),
649      read($, playback),
650      read($, lyrics),
651      read($, cover),
652      read($, busy),
653      read($, problem),
654      read($, suggestion),
655      read($, results),
656      read($, isRadio),
657      read($, theme),
658      read($, volume),
659      read($, history),
660    ])
661    const themeName = isTheme(painted) ? painted : DEFAULT_THEME
662    const accent = THEMES[themeName]
663    const shelf = all.filter(isBrowsable)
664    const shown = shelf.find(one => one.id === chosen) ?? shelf[0]
665    const listed = found ? all.find(one => one.id === found) : undefined
666    const playing = now ? all.find(one => one.id === now.albumId) : undefined
667    const track = now ? playing?.tracks.find(one => one.id === now.trackId) : undefined
668    const isHome = !track && !listed
669    const at = track && playing ? playing.tracks.indexOf(track) : -1
670    const isOneOff = playing !== undefined && ONE_OFF.has(playing.source) && follows
671    const upcoming = playing && !isOneOff ? playing.tracks.slice(at + 1, at + 1 + LAYOUT.queueSize) : []
672    const hasMore = playing && !isOneOff && at + 1 + LAYOUT.queueSize < playing.tracks.length
673    const fraction = now?.durationMs ? now.positionMs / now.durationMs : 0
674    const elapsed = now ? clockOf(now.positionMs) : ''
675    const total = now?.durationMs ? clockOf(now.durationMs) : t('unknownTime')
676    const albumLabel = (one: MusicAlbum) =>
677      one.source === 'channel' || one.source === 'link'
678        ? t('youtubeAlbum', { name: one.name })
679        : one.source === 'radio'
680          ? t('radioAlbum', { name: one.name })
681          : one.source === 'history'
682            ? t('recentAlbum')
683            : one.name
684    const subLine = () => {
685      const from = playing && playing.source !== 'search' ? albumLabel(playing) : undefined
686      return track?.byline && from ? t('subLine', { byline: track.byline, album: from }) : (track?.byline ?? from ?? '')
687    }
688    const link = track ? (track.page ?? youtubeUrlOf(config.artist, track.title)) : undefined
689
690    const artwork = () => {
691      if (e.surface === 'terminal') {
692        const bar = progressTextOf(fraction, LAYOUT.barColumns)
693        return {
694          welcome: (
695            <Box flexDirection="column">
696              <Text bold color={accent}>
697                {t('title')}
698              </Text>
699              <Text dimColor>{t('welcomeSubtitle')}</Text>
700            </Box>
701          ),
702          cover: null,
703          progress: (
704            <Text>
705              <Text color={accent}>{bar.done}</Text>
706              <Text dimColor>{bar.left}</Text>
707            </Text>
708          ),
709        }
710      }
711      const { Svg } = $.ui.resolve(e)
712      const hasCover = art && track && art.source === track.cover
713      return {
714        welcome: <Svg source={welcomeSvgOf(t('title'), t('welcomeSubtitle'), accent)} alt={t('title')} />,
715        cover: hasCover ? <Svg source={coverSvgOf(art.data)} alt={t('cover', { album: playing?.name ?? '' })} /> : null,
716        progress: <Svg source={progressSvgOf(fraction, accent)} alt={t('progress', { elapsed, total })} />,
717      }
718    }
719
720    const volumeControl = () => {
721      if (e.surface === 'terminal' || e.surface === 'desktop') {
722        const { Client } = $.ui.resolve(e)
723        const slider = {
724          level,
725          least: VOLUME.least,
726          most: VOLUME.most,
727          accent,
728          filled: ICONS.sliderFilled,
729          knob: ICONS.sliderKnob,
730          empty: ICONS.sliderEmpty,
731        }
732        return <Client key={SLIDER.volume} module="./slider.tsx" width={LAYOUT.sliderColumns} props={slider} />
733      }
734      return (
735        <Box flexDirection="row" gap={LAYOUT.buttonGap}>
736          <Button key="quieter" label={ICONS.quieter} onPress={() => changeVolume($, -VOLUME.step)} />
737          <Button key="louder" label={ICONS.louder} onPress={() => changeVolume($, VOLUME.step)} />
738        </Box>
739      )
740    }
741
742    const searchField = () => {
743      if (e.surface === 'mobile') return null
744      const { Input } = $.ui.resolve(e)
745      return (
746        <Input
747          key="search"
748          label={t('searchLabel')}
749          placeholder={t('searchPlaceholder')}
750          submitLabel={t('searchSubmit')}
751          onSubmit={value => void search($, config, value)}
752        />
753      )
754    }
755
756    const albumChoice = () => {
757      if (shelf.length < 2 || !shown) return null
758      if (e.surface === 'mobile') {
759        return (
760          <Box flexDirection="row" flexWrap="wrap" gap={LAYOUT.buttonGap}>
761            {shelf.map(one => (
762              <Button
763                key={`album:${one.id}`}
764                label={albumLabel(one)}
765                variant={one.id === shown.id ? 'primary' : 'secondary'}
766                onPress={() => chooseAlbum($, config, one.id)}
767              />
768            ))}
769          </Box>
770        )
771      }
772      const { Select } = $.ui.resolve(e)
773      return (
774        <Select
775          key="album"
776          label={t('album')}
777          value={shown.id}
778          options={shelf.map(one => ({ value: one.id, label: albumLabel(one) }))}
779          onSelect={value => void chooseAlbum($, config, value)}
780        />
781      )
782    }
783
784    const lyricLines = () => {
785      if (!now || sung?.trackId !== now.trackId) return []
786      if (sung.cues.length > 0) {
787        const line = cueIndexAt(sung.cues, now.positionMs)
788        const from = Math.max(0, line - LAYOUT.linesBefore)
789        return sung.cues
790          .slice(from, Math.max(line, 0) + LAYOUT.linesAfter + 1)
791          .map((cue, offset) =>
792            from + offset === line ? (
793              <Text bold color={accent}>
794                {cue.text}
795              </Text>
796            ) : (
797              <Text dimColor>{cue.text}</Text>
798            ),
799          )
800      }
801      if (sung.stanzas.length > 0) return sung.stanzas.map(stanza => <Text dimColor>{stanza}</Text>)
802      return [<Text dimColor>{t('noLyrics')}</Text>]
803    }
804
805    const row = (place: string, one: MusicTrack, number: string, onPress: () => void) => {
806      const details = [one.byline, one.durationMs ? clockOf(one.durationMs) : undefined].filter(Boolean).join(' · ')
807      const isCurrent = one.id === track?.id && one.album === now?.albumId
808      const name = splitTitle(one.title).name
809      return (
810        <Button key={`${place}:${one.album}|${one.id}`} plain onPress={onPress}>
811          {isCurrent ? <Text color={accent}>{`${ICONS.playing}  `}</Text> : <Text dimColor>{`${number}  `}</Text>}
812          {isCurrent ? (
813            <Text bold color={accent}>
814              {name}
815            </Text>
816          ) : (
817            <Text>{name}</Text>
818          )}
819          <Text dimColor>{details ? `   ${details}` : ''}</Text>
820        </Button>
821      )
822    }
823
824    const offerLabel = offer
825      ? offer.line
826        ? t('didYouMeanLine', { line: offer.line, title: splitTitle(offer.track.title).name })
827        : t('didYouMean', { title: splitTitle(offer.track.title).name })
828      : ''
829    const { welcome, cover: coverArt, progress: drawnProgress } = artwork()
830    const lengthSeconds = Math.floor((now?.durationMs ?? 0) / MS_PER_SECOND)
831    const seekBar = () => {
832      if ((e.surface !== 'terminal' && e.surface !== 'desktop') || lengthSeconds < 1 || !now) return drawnProgress
833      const { Client } = $.ui.resolve(e)
834      const slider = {
835        level: Math.round(now.positionMs / MS_PER_SECOND),
836        least: 0,
837        most: lengthSeconds,
838        accent,
839        filled: ICONS.sliderFilled,
840        knob: ICONS.sliderKnob,
841        empty: ICONS.sliderEmpty,
842      }
843      const width = e.surface === 'terminal' ? LAYOUT.barColumns : LAYOUT.seekWidth
844      return <Client key={SLIDER.progress} module="./slider.tsx" width={width} props={slider} />
845    }
846    const progress = seekBar()
847
848    return (
849      <Box flexDirection="column" gap={LAYOUT.sectionGap}>
850        {isHome && welcome}
851        {searchField()}
852        {loading === 'search' && <Text dimColor>{t('loading')}</Text>}
853        {offer && (
854          <Button key="suggestion" plain onPress={() => pick($, config, offer.track)}>
855            <Text color={accent}>{offerLabel}</Text>
856          </Button>
857        )}
858        {issue && <Text color={PALETTE.problem}>{t(issue.key, issue.values)}</Text>}
859        {isHome && (
860          <Box flexDirection="column">
861            {heading('quickHeading')}
862            <Box flexDirection="row" flexWrap="wrap" gap={LAYOUT.buttonGap}>
863              {QUICK_PICKS[current].map(query => (
864                <Button key={`quick:${query}`} label={query} onPress={() => search($, config, query)} />
865              ))}
866            </Box>
867          </Box>
868        )}
869        {isHome && recent.length > 0 && (
870          <Box flexDirection="column">
871            {heading('recentHeading')}
872            {recent.map((one, index) => row('recent', one, String(index + 1), () => void playRecent($, config, one)))}
873          </Box>
874        )}
875        {listed && (
876          <Box flexDirection="column">
877            <Box flexDirection="row" justifyContent="space-between">
878              {heading('resultsHeading', { query: listed.name })}
879              <Button key="closeResults" plain label={t('closeResults')} onPress={() => closeResults($)} />
880            </Box>
881            {listed.tracks.map((one, index) => row('result', one, String(index + 1), () => void pick($, config, one)))}
882          </Box>
883        )}
884        {track && now && (
885          <Box flexDirection="column" gap={LAYOUT.sectionGap}>
886            {coverArt}
887            <Box flexDirection="column">
888              <Box flexDirection="row" justifyContent="space-between" gap={LAYOUT.buttonGap}>
889                <Box flexDirection="column" flexShrink={1}>
890                  <Text bold>{splitTitle(track.title).name}</Text>
891                  <Text dimColor>{subLine()}</Text>
892                </Box>
893                {link && <Link href={link} label={t('youtube')} />}
894              </Box>
895              {now.isLoading ? <Text dimColor>{t('loading')}</Text> : progress}
896              <Box flexDirection="row" justifyContent="space-between">
897                <Text dimColor>{elapsed}</Text>
898                <Text dimColor>{total}</Text>
899              </Box>
900            </Box>
901            <Box flexDirection="column" alignItems="center">
902              <Box flexDirection="row" flexWrap="wrap" justifyContent="center" alignItems="center" gap={LAYOUT.buttonGap}>
903                <Button key="previous" label={ICONS.previous} onPress={() => skip($, config, -1)} />
904                <Button key="toggle" onPress={() => toggle($, config)}>
905                  <Text bold color={accent}>
906                    {now.isPlaying ? ICONS.pause : ICONS.play}
907                  </Text>
908                </Button>
909                <Button key="next" label={ICONS.next} onPress={() => skip($, config, 1)} />
910                <Button key="stop" label={ICONS.stop} onPress={() => stop($)} />
911              </Box>
912              <Box flexDirection="row" alignItems="center" gap={LAYOUT.buttonGap}>
913                <Text dimColor>{t('volumeLabel')}</Text>
914                {volumeControl()}
915                <Text dimColor>{t('volume', { level: String(level) })}</Text>
916              </Box>
917              <Button key="radio" plain onPress={() => toggleRadio($)}>
918                {follows ? <Text color={accent}>{t('radioOn')}</Text> : <Text dimColor>{t('radioOff')}</Text>}
919              </Button>
920            </Box>
921          </Box>
922        )}
923        {loading === 'radio' && <Text dimColor>{t('loading')}</Text>}
924        {track && now && (
925          <Box flexDirection="column">
926            {heading('lyricsHeading')}
927            {lyricLines()}
928          </Box>
929        )}
930        {track && (upcoming.length > 0 || follows) && (
931          <Box flexDirection="column">
932            {heading('upNextHeading')}
933            {upcoming.map((one, index) => row('next', one, String(index + 1), () => void play($, config, one)))}
934            {follows && !hasMore && <Text dimColor>{t('thenSimilar', { name: splitTitle(track.title).name })}</Text>}
935          </Box>
936        )}
937        {shown && (
938          <Box flexDirection="column">
939            {heading('libraryHeading')}
940            {albumChoice()}
941            {loading === 'album' && <Text dimColor>{t('loading')}</Text>}
942            {shown.tracks.map((one, index) =>
943              row('shelf', one, splitTitle(one.title).number || String(index + 1), () => void play($, config, one)),
944            )}
945          </Box>
946        )}
947        <Box flexDirection="row" alignItems="center" gap={LAYOUT.buttonGap}>
948          <Text dimColor>{t('theme')}</Text>
949          {(Object.keys(THEMES) as ThemeName[]).map(name => (
950            <Button key={`theme:${name}`} plain onPress={() => chooseTheme($, name)}>
951              <Text color={THEMES[name]}>{name === themeName ? ICONS.themeChosen : ICONS.themeOther}</Text>
952            </Button>
953          ))}
954        </Box>
955      </Box>
956    )
957  })
958}
959
hooks/art.ts 44 lines
1import { LAYOUT } from './layout'
2import { PALETTE } from './palette'
3
4const SVG = 'http://www.w3.org/2000/svg'
5
6const fractionOf = (value: number) => Math.min(1, Math.max(0, Number.isFinite(value) ? value : 0))
7
8export const coverFilterOf = (width: number, height: number) =>
9  `scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height}`
10
11export function coverSvgOf(base64: string) {
12  const { coverWidth: width, coverHeight: height, coverCorner: corner } = LAYOUT
13  return `<svg xmlns="${SVG}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><clipPath id="c"><rect width="${width}" height="${height}" rx="${corner}"/></clipPath><image href="data:image/jpeg;base64,${base64}" width="${width}" height="${height}" preserveAspectRatio="xMidYMid slice" clip-path="url(#c)"/></svg>`
14}
15
16export function progressSvgOf(fraction: number, accent: string) {
17  const { barWidth: width, barHeight: height } = LAYOUT
18  const done = Math.round(fractionOf(fraction) * width)
19  return `<svg xmlns="${SVG}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" rx="${height / 2}" fill="${PALETTE.barTrack}"/><rect width="${done}" height="${height}" rx="${height / 2}" fill="${accent}"/></svg>`
20}
21
22const escaped = (text: string) =>
23  text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
24
25export function welcomeSvgOf(title: string, subtitle: string, accent: string) {
26  const look = LAYOUT.welcome
27  const ink = PALETTE.welcomeInk
28  const columns = look.bars
29    .map((level, index) => {
30      const tall = Math.round(level * look.barReach)
31      const x = look.width - look.barRight - (look.bars.length - index) * (look.barWidth + look.barGap)
32      return `<rect x="${x}" y="${look.barBase - tall}" width="${look.barWidth}" height="${tall}" rx="${look.barWidth / 2}" fill="${ink}" fill-opacity="${look.fade}"/>`
33    })
34    .join('')
35  const heading = `<text x="${look.titleX}" y="${look.titleY}" font-family="${look.font}" font-size="${look.titleSize}" font-weight="800" fill="${ink}">${escaped(title)}</text>`
36  const lead = `<text x="${look.subtitleX}" y="${look.subtitleY}" font-family="${look.font}" font-size="${look.subtitleSize}" fill="${ink}" fill-opacity="${look.fade}">${escaped(subtitle)}</text>`
37  return `<svg xmlns="${SVG}" width="${look.width}" height="${look.height}" viewBox="0 0 ${look.width} ${look.height}"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${accent}"/><stop offset="1" stop-color="${PALETTE.welcomeBase}"/></linearGradient></defs><rect width="${look.width}" height="${look.height}" rx="${look.corner}" fill="url(#g)"/>${columns}${heading}${lead}</svg>`
38}
39
40export function progressTextOf(fraction: number, columns: number) {
41  const done = Math.round(fractionOf(fraction) * columns)
42  return { done: '━'.repeat(done), left: '─'.repeat(columns - done) }
43}
44
hooks/icons.ts 16 lines
1export const ICONS = {
2  previous: '⏴⏴',
3  play: '⏵',
4  pause: '❚❚',
5  stop: '■',
6  next: '⏵⏵',
7  playing: '♪',
8  quieter: '−',
9  louder: '+',
10  themeChosen: '◉',
11  themeOther: '●',
12  sliderFilled: '━',
13  sliderKnob: '●',
14  sliderEmpty: '─',
15}
16
hooks/layout.ts 36 lines
1export const LAYOUT = {
2  sectionGap: 1,
3  buttonGap: 1,
4  linesBefore: 2,
5  linesAfter: 3,
6  queueSize: 5,
7  recentSize: 6,
8  coverWidth: 480,
9  coverHeight: 300,
10  coverCorner: 14,
11  barWidth: 1000,
12  barHeight: 10,
13  barColumns: 24,
14  sliderColumns: 24,
15  seekWidth: '100%',
16  welcome: {
17    width: 1000,
18    height: 300,
19    corner: 28,
20    bars: [0.35, 0.7, 0.5, 0.9, 0.6, 0.8, 0.4],
21    barWidth: 18,
22    barGap: 12,
23    barRight: 70,
24    barBase: 190,
25    barReach: 150,
26    titleX: 56,
27    titleY: 160,
28    titleSize: 104,
29    subtitleX: 60,
30    subtitleY: 222,
31    subtitleSize: 32,
32    fade: 0.75,
33    font: 'Segoe UI, system-ui, sans-serif',
34  },
35}
36
hooks/library.ts 164 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { MusicAlbum, MusicCue, MusicTrack } from '../types'
4
5const AUDIO = /\.(mp3|flac|ogg|opus|m4a|wav)$/i
6const SYNCED = /\.srt$/i
7const PLAIN = /\.txt$/i
8const IMAGE = /\.(png|jpe?g|webp)$/i
9const COVER_NAME = /cover|folder|front|portada/i
10const LINE_BREAK = /\r?\n/
11const STAMP = /(\d+):(\d+):(\d+)[,.](\d+)\s*-->/
12const MARKUP = /<[^>]*>|\{[^}]*\}/g
13const NOTES = /^[\s♪♫]+|[\s♪♫]+$/g
14const MUSIC_NOTE = '♪'
15const REPORT = /@(pos|len) (\d+(?:\.\d+)?)/g
16const REPORT_MARK = '@'
17const WEB_ADDRESS = /\S+:\/\/\S+/g
18const COMPLAINT_LIMIT = 160
19const NUMBERED = /^(\d+)\s*-\s*(.+)$/
20const YOUTUBE_SEARCH = 'https://www.youtube.com/results?search_query='
21
22type Slot = 'audio' | 'synced' | 'plain'
23
24const slotOf = (name: string): Slot | undefined =>
25  AUDIO.test(name) ? 'audio' : SYNCED.test(name) ? 'synced' : PLAIN.test(name) ? 'plain' : undefined
26
27const isVisible = (name: string) => !name.startsWith('.')
28
29const byName = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true })
30
31export const joinPath = (dir: string, name: string) =>
32  `${dir.replace(/[\\/]+$/, '')}${dir.includes('\\') ? '\\' : '/'}${name}`
33
34export const nameOf = (path: string) => path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
35
36export function tracksOf(albumId: string, files: readonly string[]): MusicTrack[] {
37  const found = new Map<string, Partial<Record<Slot, string>>>()
38  for (const file of files) {
39    const name = nameOf(file)
40    const slot = slotOf(name)
41    if (!slot) continue
42    const stem = name.replace(/\.[^.]+$/, '')
43    const entry = found.get(stem) ?? {}
44    entry[slot] ??= file
45    found.set(stem, entry)
46  }
47  return [...found]
48    .flatMap(([title, entry]) =>
49      entry.audio
50        ? [{ id: entry.audio, album: albumId, title, audio: entry.audio, synced: entry.synced, plain: entry.plain }]
51        : [],
52    )
53    .sort((a, b) => byName(a.title, b.title))
54}
55
56export function coverOf(files: readonly string[]) {
57  const images = files.filter(file => IMAGE.test(nameOf(file))).sort(byName)
58  return images.find(file => COVER_NAME.test(nameOf(file))) ?? images[0]
59}
60
61async function filesOf($: EngineInterface, dir: string) {
62  const entries = await $.fs.list(dir)
63  const own = entries.filter(entry => entry.kind === 'file').map(entry => joinPath(dir, entry.name))
64  const nested = await Promise.all(
65    entries
66      .filter(entry => entry.kind === 'dir' && isVisible(entry.name))
67      .map(async entry => {
68        const inner = joinPath(dir, entry.name)
69        const listed = await $.fs.list(inner).catch(() => [])
70        return listed.filter(file => file.kind === 'file').map(file => joinPath(inner, file.name))
71      }),
72  )
73  return { own, all: [...own, ...nested.flat()] }
74}
75
76export async function scanLibrary($: EngineInterface, root: string): Promise<MusicAlbum[]> {
77  const folders = (await $.fs.list(root))
78    .filter(entry => entry.kind === 'dir' && isVisible(entry.name))
79    .map(entry => entry.name)
80    .sort(byName)
81  const albums = await Promise.all(
82    folders.map(async (name): Promise<MusicAlbum> => {
83      const id = joinPath(root, name)
84      const { own, all } = await filesOf($, id)
85      const cover = coverOf(own)
86      const tracks = tracksOf(id, all).map(track => ({ ...track, cover }))
87      return { id, name, source: 'local', tracks, isLoaded: true }
88    }),
89  )
90  return albums.filter(album => album.tracks.length > 0)
91}
92
93const millisecondsOf = (hours: string, minutes: string, seconds: string) =>
94  Math.round(((Number(hours) * 60 + Number(minutes)) * 60 + Number(seconds)) * 1000)
95
96const blocksOf = (text: string) => text.trimStart().split(LINE_BREAK).join('\n').split(/\n\s*\n/)
97
98export function parseSrt(text: string): MusicCue[] {
99  return blocksOf(text)
100    .flatMap(block => {
101      const lines = block.split('\n')
102      const at = lines.findIndex(line => STAMP.test(line))
103      const stamp = at < 0 ? null : lines[at]!.match(STAMP)
104      if (!stamp) return []
105      const [, hours, minutes, seconds, fraction] = stamp as unknown as [string, string, string, string, string]
106      const words = lines.slice(at + 1).join(' ').replace(MARKUP, '').replace(/\s+/g, ' ').trim()
107      const sung = words.replace(NOTES, '')
108      const startMs = millisecondsOf(hours, minutes, `${seconds}.${fraction.padEnd(3, '0').slice(0, 3)}`)
109      return words ? [{ startMs, text: sung || MUSIC_NOTE }] : []
110    })
111    .sort((a, b) => a.startMs - b.startMs)
112}
113
114export function stanzasOf(text: string): string[] {
115  return blocksOf(text)
116    .map(stanza => stanza.trim())
117    .filter(Boolean)
118}
119
120export function cueIndexAt(cues: readonly MusicCue[], positionMs: number) {
121  let index = -1
122  for (const [at, cue] of cues.entries()) {
123    if (cue.startMs > positionMs) break
124    index = at
125  }
126  return index
127}
128
129export function reportOf(output: string) {
130  const report: { positionMs?: number; lengthMs?: number } = {}
131  for (const [, kind, seconds] of output.matchAll(REPORT)) {
132    const ms = Math.round(Number(seconds) * 1000)
133    if (kind === 'pos') report.positionMs = ms
134    else report.lengthMs = ms
135  }
136  return report
137}
138
139export const complaintOf = (output: string) =>
140  output
141    .split(LINE_BREAK)
142    .map(line => line.replace(WEB_ADDRESS, '').trim())
143    .find(line => line !== '' && !line.startsWith(REPORT_MARK))
144    ?.slice(0, COMPLAINT_LIMIT)
145
146export function clockOf(positionMs: number) {
147  const seconds = Math.max(0, Math.floor(positionMs / 1000))
148  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
149}
150
151export function splitTitle(title: string) {
152  const found = title.match(NUMBERED)
153  return found ? { number: found[1]!, name: found[2]! } : { number: '', name: title }
154}
155
156export function hashOf(text: string) {
157  let hash = 5381
158  for (const char of text) hash = (Math.imul(hash, 33) ^ char.codePointAt(0)!) >>> 0
159  return hash.toString(36)
160}
161
162export const youtubeUrlOf = (artist: string, title: string) =>
163  `${YOUTUBE_SEARCH}${encodeURIComponent([artist.trim(), splitTitle(title).name].filter(Boolean).join(' '))}`
164
hooks/locales.ts 113 lines
1const en = {
2  title: 'Music',
3  commandDescription: 'Open the music player beside the chat',
4  commandOpened: 'Music player opened.',
5  toolPlaying: 'Playing {title} in the music player.',
6  libraryMissing: 'The music folder {path} cannot be read.',
7  playerFailed: '{player} could not play {title}.',
8  playerFailedReason: '{player} could not play {title}: {reason}',
9  toolUnavailable: 'Could not run {tool}. Install it, or set its full path in the mod options.',
10  youtubeFailed: 'yt-dlp could not read {url}.',
11  nothingFound: 'Nothing found for {query}.',
12  idle: 'Search a song, a singer or a line of the lyrics.',
13  loading: 'Loading…',
14  unknownTime: '--:--',
15  subLine: '{byline} · {album}',
16  youtubeAlbum: '{name} · YouTube',
17  radioAlbum: 'Like {name}',
18  cover: 'Cover of {album}',
19  progress: '{elapsed} of {total}',
20  youtube: 'YouTube',
21  searchLabel: 'Search',
22  searchPlaceholder: 'A song, a singer, a line of the lyrics, or a YouTube link',
23  searchSubmit: 'Search',
24  didYouMean: 'Did you mean {title}?',
25  didYouMeanLine: 'Did you mean "{line}" ({title})?',
26  resultsHeading: 'Results for {query}',
27  closeResults: 'Close',
28  lyricsHeading: 'Lyrics',
29  upNextHeading: 'Up next',
30  thenSimilar: 'Then: songs like {name}',
31  libraryHeading: 'Library',
32  album: 'Album',
33  theme: 'Theme',
34  radioOn: 'Similar songs next: on',
35  radioOff: 'Similar songs next: off',
36  noLyrics: 'This song has no lyrics.',
37  welcomeSubtitle: 'Search, listen, and similar songs keep coming',
38  quickHeading: 'Try',
39  recentHeading: 'Played lately',
40  recentAlbum: 'Played lately',
41  volume: '{level} %',
42  volumeLabel: 'Volume',
43}
44
45export type MessageKey = keyof typeof en
46
47const es: Record<MessageKey, string> = {
48  title: 'Música',
49  commandDescription: 'Abre el reproductor de música junto al chat',
50  commandOpened: 'Reproductor de música abierto.',
51  toolPlaying: 'Sonando {title} en el reproductor.',
52  libraryMissing: 'No se puede leer la carpeta de música {path}.',
53  playerFailed: '{player} no pudo reproducir {title}.',
54  playerFailedReason: '{player} no pudo reproducir {title}: {reason}',
55  toolUnavailable: 'No se pudo ejecutar {tool}. Instálalo o pon su ruta completa en las opciones del mod.',
56  youtubeFailed: 'yt-dlp no pudo leer {url}.',
57  nothingFound: 'No se encontró nada para {query}.',
58  idle: 'Busca una canción, un cantante o una frase de la letra.',
59  loading: 'Cargando…',
60  unknownTime: '--:--',
61  subLine: '{byline} · {album}',
62  youtubeAlbum: '{name} · YouTube',
63  radioAlbum: 'Parecidas a {name}',
64  cover: 'Portada de {album}',
65  progress: '{elapsed} de {total}',
66  youtube: 'YouTube',
67  searchLabel: 'Buscar',
68  searchPlaceholder: 'Una canción, un cantante, una frase de la letra o un enlace de YouTube',
69  searchSubmit: 'Buscar',
70  didYouMean: '¿Quisiste decir {title}?',
71  didYouMeanLine: '¿Quisiste decir «{line}» ({title})?',
72  resultsHeading: 'Resultados de {query}',
73  closeResults: 'Cerrar',
74  lyricsHeading: 'Letra',
75  upNextHeading: 'A continuación',
76  thenSimilar: 'Después: parecidas a {name}',
77  libraryHeading: 'Biblioteca',
78  album: 'Disco',
79  theme: 'Tema',
80  radioOn: 'Seguir con parecidas: sí',
81  radioOff: 'Seguir con parecidas: no',
82  noLyrics: 'Esta canción no tiene letra.',
83  welcomeSubtitle: 'Busca, escucha y siguen llegando canciones parecidas',
84  quickHeading: 'Prueba con',
85  recentHeading: 'Escuchadas hace poco',
86  recentAlbum: 'Escuchadas hace poco',
87  volume: '{level} %',
88  volumeLabel: 'Volumen',
89}
90
91const LOCALES = {
92  en: { names: ['en', 'english', 'inglés'], messages: en },
93  es: { names: ['es', 'español', 'espanol', 'spanish', 'castellano'], messages: es },
94}
95
96export type Locale = keyof typeof LOCALES
97
98export const QUICK_PICKS: Record<Locale, string[]> = {
99  en: ['Romantic salsa', 'Rock classics', 'Lo-fi to focus', '80s hits'],
100  es: ['Salsa romántica', 'Rock en español', 'Lo-fi para concentrarse', 'Éxitos de los 80'],
101}
102
103const BASE_LOCALE: Locale = 'en'
104
105export function localeOf(language: string | undefined): Locale {
106  const name = (language ?? '').trim().toLowerCase().split(/[-_\s]/)[0] ?? ''
107  const found = (Object.keys(LOCALES) as Locale[]).find(locale => LOCALES[locale].names.includes(name))
108  return found ?? BASE_LOCALE
109}
110
111export const translate = (locale: Locale, key: MessageKey, values: Record<string, string> = {}) =>
112  LOCALES[locale].messages[key].replace(/\{(\w+)\}/g, (whole, name: string) => values[name] ?? whole)
113
hooks/palette.ts 24 lines
1export const THEMES = {
2  pink: '#e0457b',
3  red: '#f85149',
4  orange: '#f0883e',
5  yellow: '#d4a72c',
6  green: '#3fb950',
7  cyan: '#39c5cf',
8  blue: '#4f8ff7',
9  purple: '#a371f7',
10}
11
12export type ThemeName = keyof typeof THEMES
13
14export const DEFAULT_THEME: ThemeName = 'pink'
15
16export const isTheme = (value: unknown): value is ThemeName => typeof value === 'string' && value in THEMES
17
18export const PALETTE = {
19  problem: '#e5a50a',
20  barTrack: '#4a4a4a',
21  welcomeBase: '#18181b',
22  welcomeInk: '#ffffff',
23}
24
hooks/search.ts 84 lines
1import type { MusicTrack } from '../types'
2
3const SUGGEST_AT = 0.6
4
5export type Searchable = {
6  track: MusicTrack
7  album: string
8  artist: string
9  lines: string[]
10}
11
12export type LocalSearch = {
13  hits: MusicTrack[]
14  suggestion?: { track: MusicTrack; line?: string }
15}
16
17const BRACKETED = /[([][^)\]]*[)\]]/g
18const FEATURING = /\s(?:ft|feat|featuring)\b.*$/i
19const NUMBERED = /^\d+\s*-\s*/
20
21export const normalized = (text: string) =>
22  text
23    .normalize('NFD')
24    .replace(/\p{M}/gu, '')
25    .toLowerCase()
26    .replace(/[^\p{L}\p{N}]+/gu, ' ')
27    .trim()
28
29export function songKeyOf(title: string) {
30  const bare = title.replace(BRACKETED, ' ').replace(NUMBERED, '').replace(FEATURING, '')
31  const at = bare.lastIndexOf(' - ')
32  return normalized(at < 0 ? bare : bare.slice(at + 3))
33}
34
35function bigramsOf(text: string) {
36  const padded = ` ${text} `
37  const grams = new Map<string, number>()
38  for (let at = 0; at < padded.length - 1; at++) {
39    const gram = padded.slice(at, at + 2)
40    grams.set(gram, (grams.get(gram) ?? 0) + 1)
41  }
42  return grams
43}
44
45export function similarity(a: string, b: string) {
46  if (!a || !b) return 0
47  const left = bigramsOf(a)
48  const right = bigramsOf(b)
49  let shared = 0
50  for (const [gram, count] of left) shared += Math.min(count, right.get(gram) ?? 0)
51  const total = [...left.values(), ...right.values()].reduce((sum, count) => sum + count, 0)
52  return (2 * shared) / total
53}
54
55export function closeness(query: string, text: string) {
56  const words = text.split(' ')
57  const size = query.split(' ').length
58  if (words.length <= size) return similarity(query, text)
59  let best = 0
60  for (let at = 0; at + size <= words.length; at++) {
61    best = Math.max(best, similarity(query, words.slice(at, at + size).join(' ')))
62  }
63  return best
64}
65
66export function searchLocal(query: string, items: readonly Searchable[]): LocalSearch {
67  const wanted = normalized(query)
68  if (!wanted) return { hits: [] }
69  const hits = items
70    .filter(item => [item.track.title, item.album, item.artist, ...item.lines].some(text => normalized(text).includes(wanted)))
71    .map(item => item.track)
72  if (hits.length > 0) return { hits }
73  let best: { track: MusicTrack; line?: string; score: number } | undefined
74  for (const item of items) {
75    const byTitle = closeness(wanted, normalized(item.track.title))
76    if (byTitle > (best?.score ?? 0)) best = { track: item.track, score: byTitle }
77    for (const line of item.lines) {
78      const byLine = closeness(wanted, normalized(line))
79      if (byLine > (best?.score ?? 0)) best = { track: item.track, line, score: byLine }
80    }
81  }
82  return best && best.score >= SUGGEST_AT ? { hits: [], suggestion: { track: best.track, line: best.line } } : { hits: [] }
83}
84
hooks/youtube.ts 122 lines
1import type { MusicAlbum, MusicSource, MusicTrack } from '../types'
2
3const HOSTS = new Set(['youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com', 'youtu.be'])
4const TAGS = /\s*[([][^)\]]*\b(official|lyrics?|audio|video|visualizer)\b[^)\]]*[)\]]\s*$/i
5const SEARCH_RESULTS = 8
6
7export type YoutubeEntry = {
8  id?: string
9  title?: string
10  duration?: number | null
11  url?: string
12  channel?: string | null
13  uploader?: string | null
14}
15
16export type YoutubeListing = YoutubeEntry & {
17  entries?: YoutubeEntry[]
18  webpage_url?: string
19}
20
21export type YoutubeVideo = {
22  url?: string
23  duration?: number | null
24  filesize?: number | null
25  subtitles?: Record<string, { ext?: string; url?: string }[]>
26}
27
28const WHOLE_RANGE_LIMIT = 10 * 1024 * 1024
29
30export function wholeAudioUrlOf(url: string, size: number | null | undefined) {
31  const bytes = size ?? Number(new URL(url).searchParams.get('clen'))
32  return bytes > 0 && bytes <= WHOLE_RANGE_LIMIT ? `${url}&range=0-${bytes - 1}` : url
33}
34
35export function isYoutubeLink(text: string) {
36  try {
37    const url = new URL(text.trim())
38    return url.protocol === 'https:' && HOSTS.has(url.hostname)
39  } catch {
40    return false
41  }
42}
43
44export const watchUrlOf = (id: string) => `https://www.youtube.com/watch?v=${encodeURIComponent(id)}`
45
46export const thumbnailOf = (id: string) => `https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg`
47
48export const mixUrlOf = (id: string) => `${watchUrlOf(id)}&list=RD${encodeURIComponent(id)}`
49
50export const searchOf = (query: string, results = SEARCH_RESULTS) => `ytsearch${results}:${query.trim()}`
51
52export function videoIdOf(page: string) {
53  try {
54    return new URL(page).searchParams.get('v') ?? undefined
55  } catch {
56    return undefined
57  }
58}
59
60export function playlistsUrlOf(channel: string) {
61  const trimmed = channel.trim().replace(/\/+$/, '')
62  if (trimmed.startsWith('https://')) return trimmed.endsWith('/playlists') ? trimmed : `${trimmed}/playlists`
63  return `https://www.youtube.com/${trimmed.startsWith('@') ? trimmed : `@${trimmed}`}/playlists`
64}
65
66export function cleanTitle(title: string, artist: string) {
67  const bare = title.replace(TAGS, '').trim()
68  const lead = `${artist.trim()} - `.toLowerCase()
69  return artist.trim() && bare.toLowerCase().startsWith(lead) ? bare.slice(lead.length).trim() : bare
70}
71
72export function splitCredit(title: string) {
73  const at = title.indexOf(' - ')
74  return at > 0 ? { credit: title.slice(0, at).trim(), song: title.slice(at + 3).trim() } : { credit: undefined, song: title }
75}
76
77export const durationMsOf = (seconds: number | null | undefined) => (seconds ? Math.round(seconds * 1000) : undefined)
78
79export function channelAlbumsOf(listing: YoutubeListing): MusicAlbum[] {
80  return (listing.entries ?? []).flatMap((entry): MusicAlbum[] =>
81    entry.url && entry.title
82      ? [{ id: entry.url, name: entry.title, source: 'channel', url: entry.url, tracks: [], isLoaded: false }]
83      : [],
84  )
85}
86
87export function youtubeTracksOf(albumId: string, listing: YoutubeListing, artist: string): MusicTrack[] {
88  return (listing.entries ?? [listing]).flatMap((entry): MusicTrack[] => {
89    if (!entry.id || !entry.title) return []
90    const owner = entry.channel ?? entry.uploader ?? undefined
91    const { credit, song } = splitCredit(cleanTitle(entry.title, artist))
92    return [
93      {
94        id: watchUrlOf(entry.id),
95        album: albumId,
96        title: song,
97        page: watchUrlOf(entry.id),
98        cover: thumbnailOf(entry.id),
99        byline: credit ?? owner,
100        durationMs: durationMsOf(entry.duration),
101      },
102    ]
103  })
104}
105
106export function listingAlbumOf(id: string, name: string, source: MusicSource, tracks: MusicTrack[]): MusicAlbum {
107  return { id, name, source, url: source === 'link' ? id : undefined, tracks, isLoaded: true }
108}
109
110export function linkAlbumOf(link: string, listing: YoutubeListing, artist: string): MusicAlbum {
111  const id = listing.webpage_url ?? link
112  const owner = artist || listing.channel || listing.uploader || ''
113  const name = listing.entries ? (listing.title ?? link) : cleanTitle(listing.title ?? link, owner)
114  return listingAlbumOf(id, name, 'link', youtubeTracksOf(id, listing, artist))
115}
116
117export function subtitleUrlOf(video: YoutubeVideo, language: string) {
118  const tracks = Object.entries(video.subtitles ?? {})
119  const ordered = [...tracks.filter(([code]) => code.split('-')[0] === language), ...tracks]
120  return ordered.map(([, formats]) => formats.find(format => format.ext === 'srt')?.url).find(Boolean)
121}
122
hooks/slider.tsx 42 lines
1import type { ClientModule } from 'claude-code'
2
3type SliderProps = {
4  level: number
5  least: number
6  most: number
7  accent: string
8  filled: string
9  knob: string
10  empty: string
11}
12
13type SliderState = {
14  level: number
15  isDragging: boolean
16}
17
18const Slider: ClientModule<SliderProps, SliderState> = (props, surface) => {
19  const { Box, Text } = surface.elements
20  const columns = Math.max(2, surface.columns)
21  const span = props.most - props.least
22  const levelAt = (x: number) => Math.round(Math.min(1, Math.max(0, x / (columns - 1))) * span + props.least)
23  const shown = surface.state?.isDragging ? surface.state.level : props.level
24  surface.onPointer(event => {
25    const isDragging = event.type === 'down' || (event.type === 'move' && event.button !== undefined)
26    if (!isDragging && event.type !== 'up') return
27    const level = levelAt(event.fine?.x ?? event.x)
28    if (level !== surface.state?.level || !isDragging) surface.post({ level, isDone: !isDragging })
29    surface.setState({ level, isDragging })
30  })
31  const knobAt = Math.round(((shown - props.least) / span) * (columns - 1))
32  return (
33    <Box flexDirection="row">
34      <Text color={props.accent}>{props.filled.repeat(knobAt)}</Text>
35      <Text color={props.accent}>{props.knob}</Text>
36      <Text dimColor>{props.empty.repeat(columns - knobAt - 1)}</Text>
37    </Box>
38  )
39}
40
41export default Slider
42
types/index.d.ts 88 lines
1export type MusicSource = 'local' | 'channel' | 'link' | 'search' | 'radio' | 'history'
2
3export type MusicTrack = {
4  id: string
5  album: string
6  title: string
7  audio?: string
8  page?: string
9  synced?: string
10  plain?: string
11  cover?: string
12  byline?: string
13  durationMs?: number
14}
15
16export type MusicAlbum = {
17  id: string
18  name: string
19  source: MusicSource
20  url?: string
21  tracks: MusicTrack[]
22  isLoaded: boolean
23}
24
25export type MusicCue = {
26  startMs: number
27  text: string
28}
29
30export type MusicLyrics = {
31  trackId: string
32  cues: MusicCue[]
33  stanzas: string[]
34}
35
36export type MusicPlayback = {
37  albumId: string
38  trackId: string
39  positionMs: number
40  durationMs?: number
41  source?: string
42  isPlaying: boolean
43  isLoading: boolean
44}
45
46export type MusicCover = {
47  source: string
48  data: string
49}
50
51export type MusicSuggestion = {
52  track: MusicTrack
53  line?: string
54}
55
56export type MusicBusy = 'album' | 'search' | 'radio'
57
58export type MusicProblem = {
59  key: 'libraryMissing' | 'playerFailed' | 'playerFailedReason' | 'toolUnavailable' | 'youtubeFailed' | 'nothingFound'
60  values?: Record<string, string>
61}
62
63declare module 'claude-code' {
64  interface McpToolInputs {
65    'mcp__music-pane__open': Record<string, never>
66    'mcp__music-pane__play': { query: string }
67  }
68
69  interface PluginState {
70    'music-pane': {
71      albums: MusicAlbum[]
72      album: string
73      playback: MusicPlayback | null
74      lyrics: MusicLyrics | null
75      cover: MusicCover | null
76      busy: MusicBusy | null
77      problem: MusicProblem | null
78      suggestion: MusicSuggestion | null
79      results: string | null
80      isRadio: boolean
81      theme: string
82      volume: number
83      history: MusicTrack[]
84      played: string[]
85    }
86  }
87}
88