SLOPSHOPPER

youtube

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

newpanecommandtoastprocesstimer
v0.1.0no licenseupdated 2026-10-07brogrammerMW/claude-youtube-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · youtube
│ ┃ YouTube ✕ › fix the failing auth test and add an audit log call │ ┃ ▶ YouTube plays here · [window] opens mpv │ ┃ search : lofi beats, rust tutorial, or past ● youtube: youtube: yt-dlp update check failed: SyntaxError: JSON Par │ ┃ Type a search and press Enter. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /youtube │ ⎿ youtube: YouTube pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · YouTube
▶ YouTube plays here · [window] opens mpv search : lofi beats, rust tutorial, or paste a link ⏎ searc Type a search and press Enter.
README

YouTube Video Mod for Claude Code

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

The YouTube Video Mod playing a video in a Claude Code pane, with search results and the description below

What it does

  • /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.
  • Real pixels or blocks. Terminals that can show images (Ghostty, kitty, WezTerm) get real pixels. Others, such as Apple Terminal, get quadrant blocks: 2×2 pixels per character cell.
  • Picture and sound in sync. One decoder runs both, so they start together and stay together.
  • Under the video: the title, the channel, views, date, length and likes, the full description, and every link in it.
  • Search while you watch. The search bar above the video takes a new search or a link. Results appear under the controls and the current video keeps playing.
  • Window mode. The window button opens the video in an mpv window instead.

Requirements

  • macOS (Linux should work, but isn't tested)
  • yt-dlp, plus ffmpeg and ffplay from FFmpeg: brew install yt-dlp ffmpeg
  • mpv, only for the window button: brew install mpv

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

Install

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.

Keys

KeyAction
sStop
mOpen in an mpv window
vSwitch view: auto, pixels, blocks
TabMove 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.

How it works

  1. Look up. yt-dlp looks the video up once and saves its details. A second, offline read gets the formats and details without another request to YouTube.
  2. Fetch. yt-dlp downloads the video and audio itself into named pipes. YouTube refuses stream links that other programs open directly, so ffmpeg never opens one.
  3. Decode. A single ffmpeg reads both pipes at real-time speed. It writes frames to a file the pane draws, and sends the sound as raw audio through another pipe to ffplay.
  4. Draw. The pane scales one fixed-size frame to fit, so resizing or switching view restarts nothing.

If in-pane playback fails, the pane says why. mpv only opens when you press window.

Development

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.

Credits

The in-pane video approach follows refact0r/claude-surf (MIT). See NOTICE.md.

Source 3 files
hooks/register.tsx 531 lines
1import { 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}
531
hooks/player.ts 262 lines
1// 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}
262
types/index.d.ts 21 lines
1export 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