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…

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.
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.
| Option | What it is |
|---|---|
library | The music folder, with one subfolder per album. |
channel | A YouTube channel (@name or its URL) whose playlists show as albums. |
artist | The artist of the music folder: searched with its songs, and taken off the front of YouTube titles. |
player, ffmpeg, ytdlp | The executables, by name on the PATH or by full path. |
openOnStart | Open the pane when a session starts. On by default. |
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).
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.
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.
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.
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.
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.
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.
The pane says why, in mpv's own words where it has them:
player option says. The same message names yt-dlp when a search or a YouTube song cannot run it.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.
hooks/register.tsx 959 lines1import { 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}
959hooks/art.ts 44 lines1import { 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
44hooks/icons.ts 16 lines1export 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}
16hooks/layout.ts 36 lines1export 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}
36hooks/library.ts 164 lines1import 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(' '))}`
164hooks/locales.ts 113 lines1const 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)
113hooks/palette.ts 24 lines1export 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}
24hooks/search.ts 84 lines1import 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}
84hooks/youtube.ts 122 lines1import 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}
122hooks/slider.tsx 42 lines1import 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
42types/index.d.ts 88 lines1export 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