Search YouTube from a pane and play picks in the pane or a floating mpv window

Search YouTube and watch videos inside a Claude Code pane, with sound, without leaving the terminal.

/youtube <search> lists results. Pick one and it plays in the pane./youtube <link> plays that video directly. It accepts youtu.be, watch?v=, /shorts/, /embed/ and /live/ links.window button opens the video in an mpv window instead.ffmpeg and ffplay from FFmpeg: brew install yt-dlp ffmpegwindow button: brew install mpvEach time you run /youtube, the mod checks Homebrew for a newer yt-dlp and upgrades it. YouTube changes often, and an old yt-dlp is the most common reason videos stop playing.
Clone the repo, then load it as a plugin folder:
git clone https://github.com/brogrammerMW/claude-youtube-mod.git
claude --plugin-dir ./claude-youtube-mod
To load it in every session, add the folder to a local plugin marketplace and install it with claude plugin install.
| Key | Action |
|---|---|
s | Stop |
m | Open in an mpv window |
v | Switch view: auto, pixels, blocks |
Tab | Move between the search bar, buttons and results |
↑ ↓ | Scroll to the description and links |
The s, m and v keys only work while the search bar is empty, so you can type searches freely.
If in-pane playback fails, the pane says why. mpv only opens when you press window.
claude plugin validate .
claude plugin test .
Playback logic lives in hooks/player.ts and is tested in hooks/player.test.ts. The engine wiring and drawing live in hooks/register.tsx.
The in-pane video approach follows refact0r/claude-surf (MIT). See NOTICE.md.
hooks/register.tsx 531 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Video } from '../types'
5import {
6 blankCells, cellBox, compact, decodeSize, decoderArgv, detailsArgv, downloadArgv, fitBox, FPS, linksIn,
7 lookupArgv, nextOverride, parseFormats, parseMeta, pickMode, rgbToBlocks, soundArgv, videoIdFrom,
8} from './player'
9import type { FrameSize, Meta, Override, PlayerMode, TermEnv } from './player'
10
11const PANE = 'youtube'
12// Theme color names, so the pane follows the person's light, dark or color-blind theme.
13// Red is the one accent: YouTube's own mark for "this is playing".
14const C = { accent: 'error', text: 'text', dim: 'inactive', faint: 'subtle', link: 'suggestion', ok: 'success' }
15const DETAIL_ROWS = 4 // title, meta strip, controls, one spacer stay visible under the video
16const FRAME_ROWS = 4 // the centered heading and the search bar above, plus the screen border's top and bottom
17const FRAME_COLS = 2 // the screen border's left and right
18const MAX_DESCRIPTION = 4000
19const results = atom({ plugin: 'youtube', key: 'results' } as const, [])
20const status = atom({ plugin: 'youtube', key: 'status' } as const, 'Type a search and press Enter.')
21const playing = atom({ plugin: 'youtube', key: 'playing' } as const, '')
22const mode = atom({ plugin: 'youtube', key: 'mode' } as const, 'blocks' as PlayerMode)
23const hasDraft = atom({ plugin: 'youtube', key: 'hasDraft' } as const, false)
24const submits = atom({ plugin: 'youtube', key: 'submits' } as const, 0)
25const override = atom({ plugin: 'youtube', key: 'override' } as const, 'auto' as Override)
26
27export function parseResults(stdout: string): Video[] {
28 return stdout
29 .split('\n')
30 .map(line => line.split('\t'))
31 .filter(cols => cols.length >= 2 && /^[\w-]{11}$/.test(cols[0] ?? ''))
32 .map(([id = '', title = '', channel = '', duration = '']) => ({
33 id,
34 title,
35 channel: channel === 'NA' ? '' : channel,
36 duration: duration === 'NA' ? 'live' : duration,
37 }))
38}
39
40async function search($: Engine, query: string) {
41 const q = query.trim()
42 if (!q) return
43 await update($, status, () => `Searching "${q}"…`)
44 try {
45 const { exitCode, stdout, stderr } = await $.process.run(
46 ['yt-dlp', `ytsearch10:${q}`, '--flat-playlist', '--no-warnings',
47 '--print', '%(id)s\t%(title)s\t%(channel)s\t%(duration_string)s'],
48 { timeoutMs: 45_000 },
49 )
50 const found = parseResults(stdout)
51 await update($, results, () => found)
52 await update($, status, () =>
53 exitCode !== 0 ? `yt-dlp failed: ${stderr.trim().split('\n').pop()}`
54 : found.length === 0 ? 'No results.'
55 : `${found.length} results for "${q}". Pick one to play.`)
56 } catch (err) {
57 await update($, status, () => `Search failed: ${String(err)} (is yt-dlp installed? brew install yt-dlp)`)
58 }
59}
60
61async function playMpv($: Engine, v: Video) {
62 const url = `https://www.youtube.com/watch?v=${v.id}`
63 await update($, status, () => `Playing: ${v.title}`)
64 // The child lives as long as this loop; mpv closing ends it.
65 void (async () => {
66 let failure = ''
67 try {
68 const player = $.process.spawn({
69 argv: ['mpv', '--force-window=immediate', '--ontop', '--autofit=640x360',
70 '--geometry=100%:0%', `--title=${v.title}`, url],
71 })
72 let stderr = ''
73 for (let step = await player.next(); ; step = await player.next()) {
74 if (step.done) {
75 if (step.value.code !== 0) failure = stderr.trim().split('\n').pop() || `mpv exited ${step.value.code}`
76 break
77 }
78 if (step.value.stream === 'stderr') stderr = (stderr + step.value.text).slice(-2000)
79 }
80 } catch (err) {
81 failure = `mpv did not start: ${String(err)}`
82 }
83 if (failure) {
84 // mpv failed (missing, or YouTube refused the stream): use YouTube's own player in the browser.
85 await update($, status, () => `mpv failed (${failure.slice(0, 120)}); opened in browser instead.`)
86 await $.process.run(['open', url])
87 }
88 })()
89}
90
91// ---- In-pane player (approach: refact0r/claude-surf, see NOTICE.md) ----
92
93type Handle = ReturnType<Engine['process']['spawn']>
94type Run = {
95 id: number
96 video: Video
97 pageUrl: string
98 dir: string
99 mode: PlayerMode
100 meta?: Meta
101 /** The fixed size ffmpeg decodes at: the pane scales it, so resizing restarts nothing. */
102 size?: FrameSize
103 framePath: string
104 /** Downloads, decoder and sound player: every child this run started, all ended on stop. */
105 children: Handle[]
106 /** Last error lines per child, for the message when playback fails. */
107 errors: Record<string, string>
108 ticker?: { cancel: () => void }
109 decoderAt: number
110 gotFrame: boolean
111 lastMtime: number
112 generation: number
113 denies: number
114 busy: boolean
115}
116
117const FIRST_FRAME_MS = 20_000
118const POLL_MS = 15
119const DENY_LIMIT = 6 // ponytail: a deny also means "not mounted yet" during a redraw; 6 frames (~0.4s) is past that race
120const BLACK_PIXEL = { rgba: 'AAAA/w==', width: 1, height: 1 }
121
122let run: Run | undefined
123let runCounter = 0
124let envCache: TermEnv | undefined
125// What the last render drew: the only size a blit may name.
126let shown: { mode: PlayerMode; cols: number; rows: number } | undefined
127
128async function readEnv($: Engine): Promise<TermEnv> {
129 return {
130 termProgram: await $.env.get('TERM_PROGRAM'),
131 term: await $.env.get('TERM'),
132 kittyWindow: await $.env.get('KITTY_WINDOW_ID'),
133 }
134}
135
136// Drains a child's pieces; resolves with its exit code (null when killed). Rejects if it cannot start.
137async function pump(child: Handle, onPiece: (stream: 'stdout' | 'stderr', text: string) => void): Promise<number | null> {
138 for (let step = await child.next(); ; step = await child.next()) {
139 if (step.done) return step.value.code
140 onPiece(step.value.stream, step.value.text)
141 }
142}
143
144const lastLine = (text: string) => text.trim().split('\n').pop() ?? ''
145
146// Starts one child for this run and keeps its stderr tail; resolves with its exit code.
147async function child($: Engine, r: Run, name: string, argv: string[]): Promise<number | null> {
148 const h = $.process.spawn({ argv })
149 r.children.push(h)
150 let stderr = ''
151 try {
152 return await pump(h, (stream, text) => { if (stream === 'stderr') stderr = (stderr + text).slice(-1000) })
153 } catch (err) {
154 stderr = String(err)
155 return -1
156 } finally {
157 if (stderr.trim()) r.errors[name] = lastLine(stderr)
158 }
159}
160
161// The player's search bar: hotkeys stay armed only while it is empty.
162async function draftChanged($: Engine, value: string) {
163 const has = value.length > 0
164 if ((await read($, hasDraft)) !== has) await update($, hasDraft, () => has)
165}
166
167async function submitFromPlayer($: Engine, value: string) {
168 await update($, hasDraft, () => false)
169 await update($, submits, n => n + 1) // a new key redraws the bar empty
170 return submitQuery($, value)
171}
172
173// The search bars: a YouTube link plays that video, anything else searches.
174async function submitQuery($: Engine, value: string) {
175 const id = videoIdFrom(value)
176 return id ? playLink($, id) : search($, value)
177}
178
179// A pasted link plays that video directly: look up its title, list it, start the player.
180async function playLink($: Engine, id: string) {
181 await update($, status, () => 'Opening the linked video…')
182 let video: Video = { id, title: `youtu.be/${id}`, channel: '', duration: '' }
183 try {
184 const { stdout } = await $.process.run(
185 ['yt-dlp', `https://www.youtube.com/watch?v=${id}`, '--no-warnings', '--skip-download',
186 '--print', '%(id)s\t%(title)s\t%(channel)s\t%(duration_string)s'],
187 { timeoutMs: 30_000 },
188 )
189 video = parseResults(stdout)[0] ?? video
190 } catch (err) {
191 $.ui.log(`youtube: title lookup failed: ${String(err)}`, { to: 'debug' })
192 }
193 await update($, results, () => [video])
194 await startPlayback($, video)
195}
196
197async function startPlayback($: Engine, v: Video) {
198 await stopPlayback($)
199 envCache ??= await readEnv($)
200 const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '')
201 const m = pickMode(envCache, await read($, override))
202 const id = ++runCounter
203 const dir = `${tmp}/yt-pane-${Date.now().toString(36)}`
204 const r: Run = {
205 id, video: v, pageUrl: `https://www.youtube.com/watch?v=${v.id}`, mode: m, dir,
206 framePath: `${dir}/frame.rgb`, children: [], errors: {}, decoderAt: Date.now(),
207 gotFrame: false, lastMtime: 0, generation: 0, denies: 0, busy: false,
208 }
209 run = r
210 const alive = () => run?.id === id
211 await update($, mode, () => m)
212 await update($, playing, () => v.title)
213 await update($, status, () => `Looking up "${v.title}"…`)
214 try {
215 await $.fs.write(`${dir}/.keep`, '')
216 } catch (err) {
217 return failInPane($, r, `no temp folder: ${String(err)}`)
218 }
219
220 // 1. Online, once: yt-dlp saves the video's details file. 2. Offline: formats and details from it.
221 const info = `${dir}/info.info.json`
222 try {
223 const looked = await $.process.run(lookupArgv(r.pageUrl, `${dir}/info`), { timeoutMs: 45_000 })
224 if (!alive()) return
225 if (looked.exitCode !== 0) return failInPane($, r, lastLine(looked.stderr) || 'yt-dlp could not look the video up')
226 const details = await $.process.run(detailsArgv(info), { timeoutMs: 20_000 })
227 if (!alive()) return
228 const formats = details.exitCode === 0 ? parseFormats(details.stdout) : undefined
229 if (!formats) return failInPane($, r, lastLine(details.stderr) || 'no playable format')
230 r.meta = parseMeta(details.stdout)
231 r.size = decodeSize(r.meta ? r.meta.width / r.meta.height : 16 / 9)
232
233 // 3. Named pipes: yt-dlp → video / audio → ffmpeg → frames + PCM → ffplay.
234 const pipes = { video: `${dir}/video.pipe`, audio: `${dir}/audio.pipe`, pcm: `${dir}/pcm.pipe` }
235 const made = await $.process.run(['mkfifo', pipes.video, pipes.pcm, ...(formats.muxed ? [] : [pipes.audio])])
236 if (!alive()) return
237 if (made.exitCode !== 0) return failInPane($, r, lastLine(made.stderr) || 'could not make the pipes')
238
239 await update($, status, () => `${m === 'pixels' ? 'pixels' : 'blocks'} · ${FPS} fps`)
240 void child($, r, 'sound', soundArgv(pipes.pcm))
241 void child($, r, 'video download', downloadArgv(info, formats.video, pipes.video))
242 if (!formats.muxed) void child($, r, 'audio download', downloadArgv(info, formats.audio, pipes.audio))
243 r.decoderAt = Date.now()
244 void watchDecoder($, r, decoderArgv(pipes.video, formats.muxed ? undefined : pipes.audio, r.size, r.framePath, pipes.pcm))
245 } catch (err) {
246 return failInPane($, r, String(err))
247 }
248 // Poll well above the frame rate: polling at the frame rate beats against ffmpeg's writes,
249 // showing a frame late and skipping the next every second or so. tick redraws only on a new frame.
250 r.ticker = $.clock.every(POLL_MS, () => void tick($, r))
251}
252
253// The decoder's end is the run's end: finished, or failed with the most telling error line.
254async function watchDecoder($: Engine, r: Run, argv: string[]) {
255 const code = await child($, r, 'decoder', argv)
256 if (run?.id !== r.id) return // stopped by us: not a failure
257 if (code === 0) return stopPlayback($, `Finished: ${r.video.title}`)
258 const why = r.errors['video download'] ?? r.errors['audio download'] ?? r.errors.decoder ?? `ffmpeg exited ${code}`
259 return failInPane($, r, why)
260}
261
262async function tick($: Engine, r: Run) {
263 if (run?.id !== r.id || r.busy || !r.size) return
264 r.busy = true
265 try {
266 const stat = await $.fs.stat(r.framePath).catch(() => undefined)
267 if (run?.id !== r.id) return
268 if (!stat) {
269 if (!r.gotFrame && Date.now() - r.decoderAt > FIRST_FRAME_MS) await failInPane($, r, 'no picture after 20 s')
270 return
271 }
272 const box = shown
273 if (!box || box.mode !== r.mode || stat.mtimeMs === r.lastMtime) return
274 r.lastMtime = stat.mtimeMs
275 r.gotFrame = true
276 const { width, height } = r.size
277 let result
278 if (r.mode === 'pixels') {
279 // The terminal reads the file and scales it into the box itself.
280 result = await $.ui.blit({
281 requestId: PANE, key: 'video', columns: box.cols, rows: box.rows,
282 source: { file: r.framePath, format: 'rgb', width, height, generation: ++r.generation },
283 })
284 } else {
285 const { base64 } = await $.fs.read(r.framePath, { as: 'bytes' })
286 const rgb = Uint8Array.fromBase64(base64)
287 if (rgb.length !== width * height * 3) return
288 result = await $.ui.blit({
289 requestId: PANE, key: 'video', columns: box.cols, rows: box.rows,
290 cells: rgbToBlocks(rgb, width, height, box.cols, box.rows),
291 })
292 }
293 if (!result.deny) r.denies = 0
294 else if (r.mode === 'pixels' && ++r.denies >= DENY_LIMIT) {
295 // This terminal shows no images: blocks now, and start in blocks next time too.
296 await update($, override, () => 'auto')
297 await switchMode($, r, 'blocks', 'Blocks: this terminal can\'t show images (v switches view)')
298 }
299 } catch (err) {
300 $.ui.log(`youtube: frame draw failed: ${String(err)}`, { to: 'debug' })
301 } finally {
302 r.busy = false
303 }
304}
305
306// Switching view only changes how the same frames are drawn: nothing restarts.
307async function switchMode($: Engine, r: Run, m: PlayerMode, why?: string) {
308 if (run?.id !== r.id) return
309 r.mode = m
310 r.denies = 0
311 r.lastMtime = 0
312 await update($, mode, () => m)
313 if (why) await update($, status, () => why)
314}
315
316async function cycleMode($: Engine) {
317 const r = run
318 if (!r || !envCache) return
319 const next = nextOverride(await read($, override))
320 await update($, override, () => next)
321 await switchMode($, r, pickMode(envCache, next), `view: ${next}`)
322}
323
324async function stopPlayback($: Engine, message?: string) {
325 const r = run
326 run = undefined
327 shown = undefined
328 await update($, playing, () => '')
329 if (message) await update($, status, () => message)
330 if (!r) return
331 r.ticker?.cancel()
332 // A silent child never wakes a read loop, so each handle is closed explicitly.
333 for (const h of r.children) void h.return({ code: null, signal: null }).catch(() => undefined)
334 r.children = []
335 if (r.dir.includes('/yt-pane-')) void $.process.run(['rm', '-rf', r.dir]).catch(() => undefined)
336}
337
338// In-pane playback failed: stop, say why in the pane and the transcript, and leave mpv to the
339// person (the window button). Nothing opens on its own.
340async function failInPane($: Engine, r: Run, why: string) {
341 if (run?.id !== r.id) return
342 await stopPlayback($)
343 $.ui.log(`youtube: couldn't play "${r.video.title}" in the pane: ${why}`)
344 await update($, status, () => `Couldn't play here: ${why.slice(0, 160)} · [window] opens it in mpv`)
345}
346
347async function fallbackToMpv($: Engine, r: Run, why: string) {
348 if (run?.id !== r.id) return
349 await stopPlayback($)
350 await update($, status, () => `in-pane failed (${why.slice(0, 100)}); opening mpv`)
351 await playMpv($, r.video)
352}
353
354let isUpdating = false
355
356// "2026.08.19" (yt-dlp) and "2026.8.19" (Homebrew) are the same version.
357export function sameVersion(a: string, b: string): boolean {
358 const norm = (v: string) => v.trim().split('.').map(n => String(Number(n))).join('.')
359 return norm(a) === norm(b)
360}
361
362async function updateYtDlp($: Engine) {
363 if (isUpdating) return
364 isUpdating = true
365 try {
366 const version = async () => (await $.process.run(['yt-dlp', '--version'])).stdout.trim()
367 const before = await version()
368 const api = await $.process.run(['curl', '-fsS', 'https://formulae.brew.sh/api/formula/yt-dlp.json'])
369 const latest: string = JSON.parse(api.stdout).versions.stable
370 if (sameVersion(before, latest)) return
371 $.ui.toast(`Updating yt-dlp ${before} → ${latest}…`)
372 // Exit code is unreliable (brew can fail on an unrelated dependency); the version tells.
373 const { stderr } = await $.process.run(['brew', 'upgrade', 'yt-dlp'], { timeoutMs: 300_000 })
374 const after = await version()
375 $.ui.toast(after !== before ? `yt-dlp updated to ${after}`
376 : `yt-dlp update failed: ${stderr.trim().split('\n').pop()}`)
377 } catch (err) {
378 $.ui.log(`youtube: yt-dlp update check failed: ${String(err)}`, { to: 'debug' })
379 } finally {
380 isUpdating = false
381 }
382}
383
384export const register: Register = on => {
385 on('session.start', async ($, e, next) => {
386 await $.command.register({ name: 'youtube', description: 'Search YouTube and play a video' })
387 return next(e)
388 })
389
390 on('session.end', async ($, e, next) => {
391 await stopPlayback($).catch(() => undefined)
392 return next(e)
393 })
394
395 on('ui.close', { id: PANE }, async ($, e, next) => {
396 await stopPlayback($).catch(() => undefined)
397 return next(e)
398 })
399
400 on('command.run', { command: 'youtube' }, async ($, e) => {
401 await $.ui.open({ id: PANE, title: 'YouTube', focus: true })
402 void updateYtDlp($)
403 const id = videoIdFrom(e.args)
404 if (id) void playLink($, id)
405 else if (e.args.trim()) void search($, e.args)
406 return { text: 'YouTube pane opened.' }
407 })
408
409 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
410 const els = $.ui.resolve(e)
411 const { Box, Text, Button } = els
412 // The mobile app draws no Input yet, and only the terminal has Image and Raster.
413 const Input = 'Input' in els ? els.Input : null
414 const Image = 'Image' in els ? els.Image : null
415 const Raster = 'Raster' in els ? els.Raster : null
416 const canPlay = e.surface === 'terminal' && Image !== null && Raster !== null
417 const title = await read($, playing)
418
419 // After a hot reload the atom survives but the child does not: show the list.
420 if (title && run && canPlay) {
421 const m = await read($, mode)
422 const meta = run.meta
423 const room = cellBox(m, e.props.bodyColumns - FRAME_COLS, e.props.scroll.bodyRows - DETAIL_ROWS - FRAME_ROWS)
424 const box = fitBox(meta ? meta.width / meta.height : 16 / 9, room.cols, room.rows)
425 shown = { mode: m, ...box }
426 run.lastMtime = 0 // a redraw reset the picture: blit the current frame again
427 const Link = 'Link' in els ? els.Link : null
428 const facts = [
429 meta?.channel || run.video.channel,
430 meta?.views !== undefined ? `${compact(meta.views)} views` : '',
431 meta?.date ?? '',
432 meta?.duration || run.video.duration,
433 meta?.likes !== undefined ? `▲ ${compact(meta.likes)}` : '',
434 ].filter(Boolean)
435 const description = (meta?.description ?? '').trim().slice(0, MAX_DESCRIPTION)
436 const links = linksIn(description)
437 const width = Math.max(20, e.props.bodyColumns - 2)
438 // Hotkeys only while the search bar is empty, so typing "stop" never stops the video.
439 const keys = !(await read($, hasDraft))
440 const bar = await read($, submits)
441 const others = (await read($, results)).filter(v => v.id !== run!.video.id).slice(0, 8)
442 return (
443 <Box flexDirection="column">
444 <Box justifyContent="space-between">
445 <Text><Text color={C.accent} bold>▶ </Text><Text color={C.dim}>NOW PLAYING</Text></Text>
446 <Text color={C.faint}>{await read($, status)}</Text>
447 </Box>
448 <Box justifyContent="center"><Text color={C.accent} bold>YouTube Video Mod</Text></Box>
449 {Input && <Input key={`q-${bar}`} label="search " placeholder="search YouTube or paste a link" submitLabel="go"
450 onInput={(value: string) => void draftChanged($, value)}
451 onSubmit={(value: string) => void submitFromPlayer($, value)} />}
452 <Box borderStyle="round" borderColor={C.accent} alignSelf="center">
453 {m === 'pixels'
454 ? <Image key="video" source={BLACK_PIXEL} columns={box.cols} rows={box.rows} alt=" " />
455 : <Raster key="video" columns={box.cols} rows={box.rows} cells={blankCells(box.cols, box.rows)} />}
456 </Box>
457 <Text bold wrap="truncate-end">{title}</Text>
458 <Text color={C.dim} wrap="truncate-end">{facts.join(' · ')}</Text>
459 <Box gap={2}>
460 <Button key="stop" plain label="stop" {...(keys ? { hotkey: 's' } : {})} onPress={() => void stopPlayback($, 'Stopped.')} />
461 <Button key="window" plain label="window" {...(keys ? { hotkey: 'm' } : {})}
462 onPress={() => { const r = run; if (r) void fallbackToMpv($, r, 'window requested') }} />
463 <Button key="view" plain label="view" {...(keys ? { hotkey: 'v' } : {})} onPress={() => void cycleMode($)} />
464 <Text color={C.faint}>{keys ? 'tab to move · ↑↓ scroll' : 'keys off while typing'}</Text>
465 </Box>
466 {others.length > 0 && (
467 <Box flexDirection="column" marginTop={1}>
468 <Text color={C.dim} bold>RESULTS</Text>
469 {others.map((v, i) => (
470 <Box key={`next-${v.id}`} gap={1}>
471 <Box width={3} flexShrink={0}><Text color={C.faint}>{String(i + 1).padStart(2)}</Text></Box>
472 <Button key={`next-play-${v.id}`} plain label={v.title.length > width - 18 ? `${v.title.slice(0, width - 19)}…` : v.title}
473 onPress={() => void startPlayback($, v)} />
474 <Text color={C.dim}>{v.duration}</Text>
475 </Box>
476 ))}
477 </Box>
478 )}
479 {description && (
480 <Box flexDirection="column" borderStyle="round" borderColor={C.faint} paddingX={1} marginTop={1} width={width}>
481 <Text color={C.dim} bold>DESCRIPTION</Text>
482 <Text color={C.text}>{description}</Text>
483 </Box>
484 )}
485 {links.length > 0 && (
486 <Box flexDirection="column" marginTop={1}>
487 <Text color={C.dim} bold>LINKS</Text>
488 {links.map((url, i) => (
489 <Box key={`link-${i}`}>
490 <Text color={C.accent}>↗ </Text>
491 {Link ? <Link href={url} label={url} /> : <Text color={C.link}>{url}</Text>}
492 </Box>
493 ))}
494 </Box>
495 )}
496 </Box>
497 )
498 }
499
500 const list = await read($, results)
501 const cols = e.viewport?.columns ?? 80
502 const room = Math.max(1, (e.viewport?.rows ?? 24) - 5)
503 const titleW = Math.max(16, cols - (canPlay ? 34 : 24))
504 const pick = (v: Video) => void (canPlay ? startPlayback($, v) : playMpv($, v))
505
506 return (
507 <Box flexDirection="column">
508 <Box justifyContent="space-between">
509 <Text><Text color={C.accent} bold>▶ YouTube</Text></Text>
510 <Text color={C.faint}>{canPlay ? 'plays here · [window] opens mpv' : 'plays in the mpv window'}</Text>
511 </Box>
512 {Input && <Input key="q" label="search " placeholder="lofi beats, rust tutorial, or paste a link" submitLabel="search"
513 autoFocus onSubmit={(value: string) => void submitQuery($, value)} />}
514 <Text color={C.dim}>{await read($, status)}</Text>
515 {list.slice(0, room).map((v, i) => (
516 <Box key={`row-${v.id}`} gap={1}>
517 <Box width={3} flexShrink={0}><Text color={C.faint}>{String(i + 1).padStart(2)}</Text></Box>
518 <Box width={titleW} flexShrink={0}>
519 <Button key={`play-${v.id}`} plain label={v.title.length > titleW ? `${v.title.slice(0, titleW - 1)}…` : v.title}
520 onPress={() => pick(v)} />
521 </Box>
522 <Box width={8} flexShrink={0}><Text color={C.dim}>{v.duration.padStart(7)}</Text></Box>
523 <Text color={C.faint} wrap="truncate-end">{v.channel}</Text>
524 {canPlay && <Button key={`win-${v.id}`} plain label="[window]" onPress={() => void playMpv($, v)} />}
525 </Box>
526 ))}
527 </Box>
528 )
529 })
530}
531hooks/player.ts 262 lines1// Pure player logic: no engine calls, so it is testable on its own.
2// Approach credit: refact0r/claude-surf (MIT), see ../NOTICE.md.
3
4// The plugin runtime has these (ES2026 base64); older lib typings do not declare them.
5declare global {
6 interface Uint8Array { toBase64(): string }
7 interface Uint8ArrayConstructor { fromBase64(text: string): Uint8Array }
8}
9
10export type PlayerMode = 'pixels' | 'blocks'
11export type Override = PlayerMode | 'auto'
12export type FrameSize = { width: number; height: number }
13export type TermEnv = { termProgram?: string; term?: string; kittyWindow?: string }
14
15const PIXEL_TERM_PROGRAMS = ['ghostty', 'wezterm']
16const PIXEL_TERMS = ['xterm-kitty', 'xterm-ghostty']
17const MAX_PIXEL_W = 960
18const MAX_PIXEL_H = 4096
19const MAX_BLOCK_COLS = 120
20const MAX_BLOCK_ROWS = 40
21const MAX_PIXEL_CELLS = 255
22export const CHROME_ROWS = 3
23
24// Guess from env: image-capable terminals get pixels, everything else (and any
25// multiplexer, which swallows image escapes) gets quadrant-block cells.
26export function pickMode(env: TermEnv, override: Override = 'auto'): PlayerMode {
27 if (override !== 'auto') return override
28 const term = (env.term ?? '').toLowerCase()
29 const program = (env.termProgram ?? '').toLowerCase()
30 if (program === 'tmux' || term.startsWith('tmux') || term.startsWith('screen')) return 'blocks'
31 if (PIXEL_TERM_PROGRAMS.includes(program) || PIXEL_TERMS.includes(term) || env.kittyWindow) return 'pixels'
32 return 'blocks'
33}
34
35export function nextOverride(now: Override): Override {
36 return now === 'auto' ? 'pixels' : now === 'pixels' ? 'blocks' : 'auto'
37}
38
39// Cells the video box takes, from the pane body's size.
40export function cellBox(mode: PlayerMode, bodyColumns: number, bodyRows: number): { cols: number; rows: number } {
41 const rows = Math.max(2, Math.floor(bodyRows) - CHROME_ROWS)
42 const cols = Math.max(2, Math.floor(bodyColumns))
43 return mode === 'pixels'
44 ? { cols: Math.min(cols, MAX_PIXEL_CELLS), rows: Math.min(rows, MAX_PIXEL_CELLS) }
45 : { cols: Math.min(cols, MAX_BLOCK_COLS), rows: Math.min(rows, MAX_BLOCK_ROWS) }
46}
47
48const even = (n: number) => Math.max(2, Math.floor(n / 2) * 2)
49
50// ---- the pipeline: yt-dlp fetches (googlevideo refuses links ffmpeg opens itself), ffmpeg
51// decodes once at a fixed size, ffplay plays the sound. Named pipes join them.
52
53const FORMAT = 'bv*[height<=480]+ba/b[height<=480]/bv*+ba/b'
54export const FPS = 15
55
56// Online, once: save yt-dlp's details file (`${base}.info.json`) for the steps below.
57export function lookupArgv(pageUrl: string, base: string): string[] {
58 return ['yt-dlp', '--no-playlist', '--no-warnings', '--skip-download', '--write-info-json', '-o', base, pageUrl]
59}
60
61// Offline, from that file: the two format ids, then one JSON line of details.
62export function detailsArgv(infoPath: string): string[] {
63 return ['yt-dlp', '--no-warnings', '--load-info-json', infoPath, '-f', FORMAT,
64 '--print', '%(requested_formats.0.format_id,format_id)s',
65 '--print', '%(requested_formats.1.format_id,format_id)s',
66 '--print', '%(.{width,height,channel,view_count,like_count,upload_date,duration_string,description})j']
67}
68
69export type Formats = { video: string; audio: string; muxed: boolean }
70
71// The first two non-JSON lines are the video and audio format ids ("NA" when one format has both).
72export function parseFormats(stdout: string): Formats | undefined {
73 const [video, audio] = stdout.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('{'))
74 if (!video || video === 'NA') return undefined
75 const muxed = !audio || audio === 'NA' || audio === video
76 return { video, audio: muxed ? video : audio, muxed }
77}
78
79// yt-dlp's own downloader into a named pipe. `exec` makes the shell become yt-dlp, so killing the
80// spawned process kills the download, with nothing left behind.
81export function downloadArgv(infoPath: string, formatId: string, pipe: string): string[] {
82 return ['/bin/sh', '-c', 'exec yt-dlp -q --no-warnings --no-part --load-info-json "$0" -f "$1" -o - > "$2"',
83 infoPath, formatId, pipe]
84}
85
86// One decode size per video, in its own shape: about 270 px tall, at most 480 wide.
87export function decodeSize(aspect: number): FrameSize {
88 const a = aspect > 0 && Number.isFinite(aspect) ? aspect : 16 / 9
89 const width = Math.min(480, even(270 * a))
90 return { width, height: even(width / a) }
91}
92
93// ffmpeg paces both inputs in real time (-re): frames go to one overwritten raw rgb24 file,
94// sound goes as raw PCM into the pipe ffplay reads. One process, one clock.
95export function decoderArgv(videoPipe: string, audioPipe: string | undefined, size: FrameSize, framePath: string, pcmPipe: string): string[] {
96 return ['ffmpeg', '-v', 'error', '-nostdin',
97 '-re', '-i', videoPipe, ...(audioPipe ? ['-re', '-i', audioPipe] : []),
98 '-map', '0:v:0', '-vf', `fps=${FPS},scale=${size.width}:${size.height}:flags=area`,
99 '-pix_fmt', 'rgb24', '-c:v', 'rawvideo', '-f', 'image2', '-update', '1', '-atomic_writing', '1', framePath,
100 '-map', audioPipe ? '1:a:0' : '0:a:0', '-ar', '48000', '-ac', '2', '-f', 's16le', '-y', pcmPipe]
101}
102
103// ffplay plays the PCM with as little buffering as it allows; it ends when ffmpeg closes the pipe.
104// (AudioToolbox straight from ffmpeg started 10-15 s late; ffplay starts at once.)
105export function soundArgv(pcmPipe: string): string[] {
106 return ['ffplay', '-nodisp', '-autoexit', '-nostats', '-loglevel', 'error',
107 '-fflags', 'nobuffer', '-flags', 'low_delay', '-probesize', '32', '-analyzeduration', '0',
108 '-f', 's16le', '-ar', '48000', '-ch_layout', 'stereo', pcmPipe]
109}
110
111export type Meta = {
112 width: number; height: number; channel: string; views?: number; likes?: number
113 date: string; duration: string; description: string
114}
115
116// The JSON line yt-dlp prints after the stream URLs. Missing or broken → undefined.
117export function parseMeta(stdout: string): Meta | undefined {
118 const line = stdout.split('\n').map(l => l.trim()).filter(l => l.startsWith('{') && !l.includes('"User-Agent"')).pop()
119 if (!line) return undefined
120 try {
121 const j = JSON.parse(line) as Record<string, unknown>
122 const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
123 const str = (v: unknown) => (typeof v === 'string' ? v : '')
124 const d = str(j.upload_date)
125 return {
126 width: num(j.width) ?? 16, height: num(j.height) ?? 9, channel: str(j.channel),
127 views: num(j.view_count), likes: num(j.like_count),
128 date: /^\d{8}$/.test(d) ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6)}` : '',
129 duration: str(j.duration_string), description: str(j.description),
130 }
131 } catch {
132 return undefined
133 }
134}
135
136// Cells a video of this shape fills inside the space given, with no bars: a cell is about
137// twice as tall as wide (8x16 px), so the box's real shape is (cols*8):(rows*16).
138// ponytail: assumes an 8x16 cell; a terminal with a different cell shape stretches slightly.
139export function fitBox(aspect: number, cols: number, rows: number): { cols: number; rows: number } {
140 const a = aspect > 0 && Number.isFinite(aspect) ? aspect : 16 / 9
141 const byWidth = { cols, rows: Math.round((cols * 8) / a / 16) }
142 const box = byWidth.rows <= rows ? byWidth : { cols: Math.round((rows * 16 * a) / 8), rows }
143 return { cols: Math.max(2, box.cols), rows: Math.max(1, box.rows) }
144}
145
146// Every distinct http(s) link in a description, in order, trailing punctuation trimmed.
147export function linksIn(text: string, max = 20): string[] {
148 const found = (text.match(/https?:\/\/[^\s<>"')\]]+/g) ?? []).map(u => u.replace(/[.,;:!?]+$/, ''))
149 return [...new Set(found)].slice(0, max)
150}
151
152// 1234 → "1.2K", 3400000 → "3.4M"
153export function compact(n: number): string {
154 for (const [div, unit] of [[1e9, 'B'], [1e6, 'M'], [1e3, 'K']] as const) {
155 if (n >= div) return `${(n / div).toFixed(n >= div * 10 ? 0 : 1).replace(/\.0$/, '')}${unit}`
156 }
157 return String(n)
158}
159
160// Quadrant blocks: each cell shows 2x2 pixels in two colors. Index = which of the four
161// pixels take the foreground (bit 1 top-left, 2 top-right, 4 bottom-left, 8 bottom-right).
162const QUADRANTS = [
163 0x0020, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b,
164 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588,
165]
166
167// rgb is (cols*2) x (rows*2) pixels, 3 bytes each. For every cell, try each way of splitting its
168// four pixels into two groups, color each group by its mean, and keep the split with the least error.
169// ponytail: 8 splits x 4 pixels per cell; at 120x40 cells and 15 fps that is ~2.3M small steps a second.
170const SPLITS = [15, 8, 9, 10, 11, 12, 13, 14]
171
172export function quadrantWords(rgb: Uint8Array, cols: number, rows: number): Uint32Array {
173 const words = new Uint32Array(cols * rows * 3)
174 const w = cols * 2
175 const px = new Int32Array(12)
176 for (let y = 0; y < rows; y++) {
177 for (let x = 0; x < cols; x++) {
178 const tl = ((y * 2) * w + x * 2) * 3
179 const bl = tl + w * 3
180 for (const [k, at] of [[0, tl], [3, tl + 3], [6, bl], [9, bl + 3]] as const) {
181 px[k] = rgb[at] ?? 0; px[k + 1] = rgb[at + 1] ?? 0; px[k + 2] = rgb[at + 2] ?? 0
182 }
183 let best = 15, bestErr = Infinity, fg = 0, bg = 0
184 // Masks 8..15 cover every split once (a split and its complement look the same);
185 // 15 (one color) goes first so a flat cell stays a plain full block on ties.
186 for (const mask of SPLITS) {
187 const f = [0, 0, 0], b = [0, 0, 0]
188 let nf = 0, nb = 0
189 for (let i = 0; i < 4; i++) {
190 const into = mask & (1 << i) ? f : b
191 into[0] += px[i * 3]!; into[1] += px[i * 3 + 1]!; into[2] += px[i * 3 + 2]!
192 if (mask & (1 << i)) nf++; else nb++
193 }
194 const fm = f.map(c => c / nf)
195 const bm = nb ? b.map(c => c / nb) : fm
196 let err = 0
197 for (let i = 0; i < 4; i++) {
198 const m = mask & (1 << i) ? fm : bm
199 for (let c = 0; c < 3; c++) { const d = px[i * 3 + c]! - m[c]!; err += d * d }
200 }
201 if (err < bestErr) {
202 bestErr = err; best = mask
203 fg = (Math.round(fm[0]!) << 16) | (Math.round(fm[1]!) << 8) | Math.round(fm[2]!)
204 bg = (Math.round(bm[0]!) << 16) | (Math.round(bm[1]!) << 8) | Math.round(bm[2]!)
205 }
206 }
207 const o = (y * cols + x) * 3
208 words[o] = QUADRANTS[best]!
209 words[o + 1] = fg
210 words[o + 2] = bg
211 }
212 }
213 return words
214}
215
216// Averages a w x h rgb frame down (or up) to outW x outH: each output pixel is the mean of the
217// source pixels under it, so small text and edges keep what detail the cells can hold.
218export function resample(rgb: Uint8Array, w: number, h: number, outW: number, outH: number): Uint8Array {
219 if (w === outW && h === outH) return rgb
220 const out = new Uint8Array(outW * outH * 3)
221 for (let y = 0; y < outH; y++) {
222 const y0 = Math.floor((y * h) / outH), y1 = Math.max(y0 + 1, Math.floor(((y + 1) * h) / outH))
223 for (let x = 0; x < outW; x++) {
224 const x0 = Math.floor((x * w) / outW), x1 = Math.max(x0 + 1, Math.floor(((x + 1) * w) / outW))
225 let r = 0, g = 0, b = 0, n = 0
226 for (let sy = y0; sy < y1; sy++) {
227 for (let sx = x0; sx < x1; sx++) {
228 const i = (sy * w + sx) * 3
229 r += rgb[i] ?? 0; g += rgb[i + 1] ?? 0; b += rgb[i + 2] ?? 0; n++
230 }
231 }
232 const o = (y * outW + x) * 3
233 out[o] = r / n; out[o + 1] = g / n; out[o + 2] = b / n
234 }
235 }
236 return out
237}
238
239// A w x h frame drawn into cols x rows quadrant cells.
240export function rgbToBlocks(rgb: Uint8Array, w: number, h: number, cols: number, rows: number): string {
241 return new Uint8Array(quadrantWords(resample(rgb, w, h, cols * 2, rows * 2), cols, rows).buffer).toBase64()
242}
243
244// A black frame for a Raster before the first blit.
245export function blankCells(cols: number, rows: number): string {
246 return rgbToBlocks(new Uint8Array(cols * rows * 12), cols * 2, rows * 2, cols, rows)
247}
248
249// The video id in a YouTube link: youtu.be/ID, watch?v=ID, /shorts/ID, /embed/ID, /live/ID.
250// Plain text (a search) gives undefined; a bare 11-character word is a search, not an id.
251export function videoIdFrom(text: string): string | undefined {
252 let url: URL
253 try { url = new URL(text.trim()) } catch { return undefined }
254 const host = url.hostname.replace(/^(www|m|music)\./, '')
255 const id = host === 'youtu.be'
256 ? url.pathname.split('/')[1]
257 : host === 'youtube.com' || host === 'youtube-nocookie.com'
258 ? url.searchParams.get('v') ?? url.pathname.match(/^\/(?:shorts|embed|live|v)\/([^/?#]+)/)?.[1]
259 : undefined
260 return id && /^[\w-]{11}$/.test(id) ? id : undefined
261}
262types/index.d.ts 21 lines1export type Video = { id: string; title: string; channel: string; duration: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 youtube: {
6 results: Video[]
7 status: string
8 /** Title of the video playing in the pane, '' when the list shows. */
9 playing: string
10 /** The mode the player draws in. */
11 mode: 'pixels' | 'blocks'
12 /** User choice for the mode: auto picks from the terminal. */
13 override: 'auto' | 'pixels' | 'blocks'
14 /** True while the player's search bar holds text: the s/m/v hotkeys are off then. */
15 hasDraft: boolean
16 /** Bumped on each player search submit, so the bar redraws empty. */
17 submits: number
18 }
19 }
20}
21