SLOPSHOPPER

yt-control

A YouTube player inside Claude Code, backed by cliamp: controls, now playing, thumbnail and playlist

newpanebandspinnercommandtoast
★ 2v1.1.0MITupdated 2026-10-04Unayung/cc-mods-youtube/plugins/yt-control
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · yt-control
│ ┃ yt-control ✕ › fix the failing auth test and add an audit log call │ ┃ cliamp is not running. Start it with /yt │ ┃ playlist <url>. ⏺ 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 │ │ › /yt │ ⎿ yt-control: cliamp is not running. Start it with /yt playlist <u │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · yt-control
cliamp is not running. Start it with /yt playlist <url>.
README

yt-control

A YouTube player inside Claude Code, built as a Claude Code mod and backed by cliamp.

Load a YouTube playlist with one command and control it without leaving the terminal. cliamp plays the audio, so no browser tab is needed.

yt-control: the Now playing pane with thumbnail, controls and queue beside a Claude Code session

  • Previous / play-pause / next controls
  • Now playing: title, artist, progress
  • The video thumbnail, drawn in the terminal (kitty graphics terminals)
  • The whole playlist in a pane; click a track to play it
  • Four placements and three icon styles, picked in /config

Requirements

WhatWhyInstall
Claude Code 2.1.288 or newerruns the mod (function hooks)claude update
macOS or Linuxthe mod shells out to sh, curl and an image converter
cliamp v2.2 or newerplays the audio; the mod drives it over cliamp remotesee cliamp's README; cliamp upgrade if you already have it
yt-dlpcliamp uses it to resolve YouTube URLsbrew install yt-dlp, pacman -S yt-dlp, pipx install yt-dlp
curldownloads the thumbnailusually preinstalled
sips (macOS, built in) or ImageMagick (Linux)converts the thumbnail to PNGpacman -S imagemagick, apt install imagemagick
A terminal with the kitty graphics protocol (kitty, Ghostty, WezTerm)draws the thumbnailoptional; other terminals show the title instead

When cliamp or yt-dlp is missing or cliamp is too old, the pane and /yt say what to install. Install it and run /yt again; no restart needed.

Install

claude plugin marketplace add Unayung/cc-mods-youtube
claude plugin install yt-control@cc-mods-youtube

Then start a new Claude Code session. The install may say that some options are not set yet; all have defaults, so this is safe to ignore.

Use

/yt playlist <YouTube URL>   load a playlist (or a single video) into cliamp and play it
/yt                          play / pause
/yt prev   /yt next          previous / next track
/yt show                     open the Now playing pane

If the cliamp daemon is not running, /yt playlist starts it in the background. You can also run cliamp's own TUI in another terminal; the mod talks to the same socket.

YouTube Mix links (watch?v=...&list=RD...) work as well as regular playlists.

The pane

[thumbnail]
Title
Artist · 1:05 / 3:34
⏮️ ⏸️ ⏭️

🎵 Queue · 20
1. A track            ← click to play
▶️ 2. Playing now
3. Next track

The thumbnail follows the pane's width. Focus the pane with ctrl+x tab; then b / p / n press previous / play-pause / next, and the arrow keys scroll the queue.

Settings

Open /config and look for yt-control:

OptionValuesDefault
Player positionabove-prompt: a band with buttons above the prompt<br>pane: the Now playing pane with thumbnail and queue<br>prompt-hint: text after the hint line under the prompt<br>status: a status lineabove-prompt
Icon styleemoji, nerd (needs a Nerd Font), unicodeemoji
Stop music on exitstop cliamp when you leave Claude Code (/exit, ctrl+c, closing the terminal); /clear and switching sessions keep it playing. Turn it off if you run cliamp on its ownon

/yt show opens the pane whatever the position is.

Troubleshooting

The thumbnail shows as the video title in grey text. Claude Code did not detect kitty graphics support. Inside a terminal multiplexer (tmux, zellij, herdr) this is expected even when the outer terminal supports it. If your multiplexer passes kitty graphics through, force it on in ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1" } }

/yt playlist briefly plays something else first. A freshly started cliamp daemon resumes the last track it played; the mod replaces the queue right after.

cliamp is not installed although it is. The mod looks in your PATH plus ~/.local/bin, /opt/homebrew/bin and /usr/local/bin. If cliamp lives elsewhere, add its directory to PATH for the process that starts Claude Code.

The pane does not appear on its own. A pane opened at startup only shows when the terminal is at least 144 columns wide. Run /yt show.

Uninstall

claude plugin uninstall yt-control@cc-mods-youtube
claude plugin marketplace remove cc-mods-youtube

Thumbnails are cached in /tmp/yt-control-art/.

How it works

The mod polls cliamp remote state every 2 seconds and reads the queue with cliamp remote call queue.list when the playlist changes. Controls call cliamp prev|toggle|next; clicking a track calls queue.play with its index. Loading runs queue.clear then url.load. Thumbnails come from i.ytimg.com by video id.

The band, pane and status line are drawn in the terminal only; the mod adds nothing to the system prompt and registers no tool for the model. The one-line reply of a /yt command (for example ▶️ Artist - Title) is an ordinary command output line in the transcript.

Development

claude --plugin-dir plugins/yt-control     # load from this checkout
claude plugin validate plugins/yt-control
claude plugin test plugins/yt-control

License

MIT

Source 2 files
hooks/register.tsx 328 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Art, NowPlaying, Queue, Track } from '../types'
5
6const now = atom({ plugin: 'yt-control', key: 'now' } as const, null)
7const queue = atom({ plugin: 'yt-control', key: 'queue' } as const, null)
8// A missing or too-old dependency, said in the pane and by /yt; null when all is there.
9const problem = atom({ plugin: 'yt-control', key: 'problem' } as const, null)
10
11const PANE = 'yt-control'
12const ART_DIR = '/tmp/yt-control-art'
13const USAGE = 'Usage: /yt [prev|toggle|next|show|playlist <url>]'
14
15export const ICONS = {
16  emoji: { prev: '⏮️', play: '▶️', pause: '⏸️', next: '⏭️', note: '🎵' },
17  nerd: { prev: '\u{F04AE}', play: '\u{F040A}', pause: '\u{F03E4}', next: '\u{F04AD}', note: '\u{F075A}' },
18  unicode: { prev: '⏮', play: '▶', pause: '⏸', next: '⏭', note: '♪' },
19}
20type Icons = (typeof ICONS)['emoji']
21
22type Action = 'prev' | 'toggle' | 'next'
23const ACTIONS: readonly string[] = ['prev', 'toggle', 'next']
24// Leaving Claude Code, as opposed to /clear or switching sessions, which keep the process (and the music) going.
25const EXIT_REASONS: readonly string[] = ['prompt_input_exit', 'logout', 'other']
26
27type CliampTrack = { title?: string; artist?: string; path?: string; duration_secs?: number }
28
29// `cliamp remote state` → .snapshot; null when nothing is loaded.
30export function parseState(snapshot: {
31  state?: string
32  track?: CliampTrack
33  position?: number
34  duration?: number
35  index?: number
36}): NowPlaying | null {
37  const t = snapshot.track
38  if (!t) return null
39  return {
40    status: snapshot.state ?? 'stopped',
41    title: t.title ?? '',
42    artist: (t.artist ?? '').trim(),
43    path: t.path ?? '',
44    position: Math.round(snapshot.position ?? 0),
45    length: Math.round(snapshot.duration ?? t.duration_secs ?? 0),
46    // cliamp omits index 0
47    index: snapshot.index ?? 0,
48    art: null,
49  }
50}
51
52// `queue.list` → .job.result.tracks
53export function parseQueue(tracks: readonly CliampTrack[]): Track[] {
54  return tracks.map(t => ({
55    path: t.path ?? '',
56    title: t.title ?? t.path ?? '',
57    artist: (t.artist ?? '').trim(),
58    length: Math.round(t.duration_secs ?? 0),
59  }))
60}
61
62export const videoId = (path: string) => path.match(/[?&]v=([\w-]{11})/)?.[1] ?? null
63
64const clock = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
65
66// Third line of the pane: who, and where in the track.
67export function infoLine(np: NowPlaying): string {
68  const time = np.length ? `${clock(np.position)} / ${clock(np.length)}` : ''
69  return [np.artist, time].filter(Boolean).join(' · ')
70}
71
72// One text line for the text-only positions: state icon, then the track.
73export function nowLine(np: NowPlaying, icons: Icons): string {
74  const state = np.status === 'playing' ? icons.play : icons.pause
75  return `${state} ${np.artist ? `${np.artist} - ` : ''}${np.title}`
76}
77
78// ponytail: counts UTF-16 units, so CJK titles (2 cells each) can still overflow and get clipped by the pane
79const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s)
80
81export const register: Register = (on, options) => {
82  const position = String(options.position ?? 'above-prompt')
83  const icons: Icons = ICONS[String(options.icons) as keyof typeof ICONS] ?? ICONS.emoji
84  const stopOnExit = options.stopOnExit !== false
85
86  // session.start owns cliamp access; the buttons and /yt reach it through this.
87  const api = {
88    send: async (_: Action): Promise<unknown> => undefined,
89    load: async (_: string): Promise<string> => 'Not ready yet.',
90    check: async (): Promise<string | null> => 'Not ready yet.',
91    stop: async (_: number): Promise<unknown> => undefined,
92    play: async (_: number): Promise<unknown> => undefined,
93  }
94
95  on('session.start', async ($, e, next) => {
96    await $.command.register({ name: 'yt', description: 'cliamp player: /yt [prev|toggle|next|show|playlist <url>]' })
97    if (position === 'pane') void $.ui.open({ id: PANE, title: 'Now playing' })
98
99    // A GUI-started Claude Code often lacks the shell's PATH, so add where cliamp and yt-dlp usually live.
100    const env = {
101      PATH: [`${await $.env.get('HOME')}/.local/bin`, '/opt/homebrew/bin', '/usr/local/bin', await $.env.get('PATH')]
102        .filter(Boolean)
103        .join(':'),
104    }
105    const run = (argv: string[], timeoutMs = 3000) => $.process.run(argv, { timeoutMs, env })
106    const cliamp = (args: string[], timeoutMs = 3000) => run(['cliamp', ...args], timeoutMs)
107    const call = (op: string, params: object, timeoutMs = 10000) =>
108      cliamp(['remote', 'call', op, '--wait', '--params', JSON.stringify(params)], timeoutMs)
109
110    // YouTube's 320x180 thumbnail by video id, cached as PNG (sips on macOS, ImageMagick on Linux).
111    const thumb = async (path: string): Promise<Art | null> => {
112      const id = videoId(path)
113      if (!id) return null
114      const file = `${ART_DIR}/${id}.png`
115      // No converter (sips / ImageMagick 7 / 6) just means no picture.
116      const r = await run(
117        [
118          'sh',
119          '-c',
120          '[ -s "$2" ] || { mkdir -p "$(dirname "$2")" && curl -sf "$1" -o "$2.jpg" && { sips -s format png "$2.jpg" --out "$2" >/dev/null 2>&1 || magick "$2.jpg" "$2" 2>/dev/null || convert "$2.jpg" "$2"; }; }',
121          'sh',
122          `https://i.ytimg.com/vi/${id}/mqdefault.jpg`,
123          file,
124        ],
125        10000,
126      )
127      return r.exitCode === 0 ? { file, format: 'png' } : null
128    }
129
130    let queueRevision = -1
131    let lastStatus: string | undefined
132    // A malformed answer from cliamp skips one tick rather than throwing every 2s.
133    const refresh = () => tick().catch(() => undefined)
134    const tick = async () => {
135      const r = await cliamp(['remote', 'state'])
136      if (r.exitCode !== 0) {
137        queueRevision = -1
138        await update($, queue, () => null)
139        await update($, now, () => null)
140      } else {
141        const snapshot = JSON.parse(r.stdout).snapshot ?? {}
142        const np = parseState(snapshot)
143        const prev = await read($, now)
144        const art = !np || position !== 'pane' ? null : prev?.path === np.path ? prev.art : await thumb(np.path)
145        await update($, now, () => (np ? { ...np, art } : null))
146
147        if (snapshot.playlist_revision !== queueRevision) {
148          const q = await call('queue.list', { offset: 0, limit: 500 })
149          if (q.exitCode === 0) {
150            const j = JSON.parse(q.stdout)
151            queueRevision = snapshot.playlist_revision
152            const fresh: Queue = { revision: queueRevision, tracks: parseQueue(j.job?.result?.tracks ?? []) }
153            await update($, queue, () => fresh)
154          }
155        }
156      }
157
158      if (position !== 'status') return
159      const np = await read($, now)
160      const line = np ? nowLine(np, icons) : undefined
161      if (line !== lastStatus) $.ui.status((lastStatus = line))
162    }
163
164    const doctor = async (): Promise<string | null> => {
165      const has = async (bin: string) => (await run(['sh', '-c', 'command -v "$1"', 'sh', bin])).exitCode === 0
166      if (!(await has('cliamp'))) return 'cliamp is not installed: https://github.com/bjarneo/cliamp#install'
167      if ((await cliamp(['remote', '--help'])).exitCode !== 0) return 'cliamp is too old (needs v2.2+ for `cliamp remote`). Run: cliamp upgrade'
168      if (!(await has('yt-dlp'))) return 'yt-dlp is not installed (cliamp needs it for YouTube): https://github.com/yt-dlp/yt-dlp#installation'
169      return null
170    }
171
172    // Poll only once the dependencies are there; /yt checks again, so installing them needs no restart.
173    let isPolling = false
174    api.check = async () => {
175      const found = await doctor()
176      await update($, problem, () => found)
177      if (!found && !isPolling) {
178        isPolling = true
179        // ponytail: 2s polling; switch to `cliamp remote events` via $.process.spawn if the progress should tick live
180        $.clock.every(2000, () => void refresh())
181        void refresh()
182      }
183      return found
184    }
185    // The session may end (and the module unload) before this settles.
186    void api.check().catch(() => undefined)
187
188    // The daemon resumes its last track on start; loading replaces that straight away.
189    const ensureDaemon = async () => {
190      if ((await cliamp(['remote', 'state'])).exitCode === 0) return true
191      await run(['sh', '-c', 'nohup cliamp --daemon >/dev/null 2>&1 &'])
192      for (let i = 0; i < 20; i++) {
193        await $.clock.sleep(250)
194        if ((await cliamp(['remote', 'state'])).exitCode === 0) return true
195      }
196      return false
197    }
198
199    let isLoading = false
200    api.load = async (url: string) => {
201      if (isLoading) return 'Already loading a playlist.'
202      isLoading = true
203      try {
204        if (!(await ensureDaemon())) return 'Could not start the cliamp daemon.'
205        $.ui.toast(`${icons.note} Loading playlist…`)
206        await call('queue.clear', {})
207        const r = await call('url.load', { path: url, play: true }, 120000)
208        const j = r.exitCode === 0 ? JSON.parse(r.stdout) : null
209        await refresh()
210        return j?.job?.result?.ok ? `Loaded ${j.job.result.total} track${j.job.result.total === 1 ? '' : 's'} into cliamp.` : `cliamp could not load it: ${r.stdout || r.stderr}`
211      } catch (err) {
212        return `Loading failed: ${String(err)}`
213      } finally {
214        isLoading = false
215      }
216    }
217    api.send = async (action: Action) => {
218      await cliamp([action])
219      await refresh()
220    }
221    api.play = async (index: number) => {
222      await call('queue.play', { index })
223      await refresh()
224    }
225    api.stop = (timeoutMs: number) => cliamp(['stop'], timeoutMs)
226
227    return next(e)
228  })
229
230  // ponytail: stops cliamp even when another Claude Code window started the music; track ownership if that bites
231  on('session.end', async ($, e, next) => {
232    if (stopOnExit && EXIT_REASONS.includes(e.reason)) {
233      // Exits share one short budget; a missing or idle cliamp must not hold the exit up.
234      await api.stop(Math.max(1, Math.min(1000, next.budget.remainingMs - 50))).catch(() => undefined)
235    }
236    return next(e)
237  })
238
239  on('command.run', { command: 'yt' }, async ($, e) => {
240    const missing = await api.check()
241    if (missing) return { text: missing }
242    const arg = e.args.trim() || 'toggle'
243    if (arg === 'show') {
244      await $.ui.open({ id: PANE, title: 'Now playing' })
245      return { text: 'Now playing pane opened.' }
246    }
247    if (arg === 'playlist' || arg.startsWith('playlist ')) {
248      const url = arg.slice('playlist'.length).trim()
249      if (!url) return { text: USAGE }
250      const text = await api.load(url)
251      await $.ui.open({ id: PANE, title: 'Now playing' })
252      return { text }
253    }
254    if (!ACTIONS.includes(arg)) return { text: USAGE }
255    await api.send(arg as Action)
256    const np = await read($, now)
257    return { text: np ? nowLine(np, icons) : 'cliamp is not running. Start it with /yt playlist <url>.' }
258  })
259
260  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
261    const np = position === 'prompt-hint' ? await read($, now) : null
262    return next(np ? { ...e, props: { ...e.props, tail: `${icons.note} ${nowLine(np, icons)}` } } : e)
263  })
264
265  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
266    const np = position === 'above-prompt' ? await read($, now) : null
267    if (!np || e.props.hasSurvey) return next(e)
268
269    const { Box, Button, Text } = $.ui.resolve(e)
270    const isPlaying = np.status === 'playing'
271
272    return (
273      <Box>
274        <Button key="prev" label={icons.prev} hotkey="b" plain onPress={() => api.send('prev')} />
275        <Text> </Text>
276        <Button key="toggle" label={isPlaying ? icons.pause : icons.play} hotkey="p" plain onPress={() => api.send('toggle')} />
277        <Text> </Text>
278        <Button key="next" label={icons.next} hotkey="n" plain onPress={() => api.send('next')} />
279        <Text dimColor>
280          {'  '}
281          {icons.note} {np.artist ? `${np.artist} - ` : ''}
282          {np.title}
283        </Text>
284      </Box>
285    )
286  })
287
288  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
289    const el = $.ui.resolve(e)
290    const { Box, Button, Text } = el
291    // Image is the terminal's alone; other surfaces get the text and buttons.
292    const Image = 'Image' in el ? el.Image : undefined
293    const missing = await read($, problem)
294    if (missing) return <Text color="yellow">{missing}</Text>
295    const np = await read($, now)
296    if (!np) return <Text dimColor>{'cliamp is not running. Start it with /yt playlist <url>.'}</Text>
297    const q = await read($, queue)
298
299    const isPlaying = np.status === 'playing'
300    // Pane body width (viewport is the whole screen); a cell is about twice as tall as wide, so 16:9 is width * 9/32 rows.
301    const columns = Math.min(255, Math.max(8, e.props.bodyColumns))
302    const rows = Math.min(Math.max(1, (e.viewport?.rows ?? 24) - 8), Math.round((columns * 9) / 32))
303    return (
304      <Box flexDirection="column">
305        {np.art && Image && <Image source={np.art} columns={columns} rows={rows} alt={np.title || ' '} />}
306        <Text bold wrap="truncate-end">{np.title}</Text>
307        <Text dimColor wrap="truncate-end">{infoLine(np)}</Text>
308        <Box>
309          <Button key="prev" label={icons.prev} hotkey="b" plain onPress={() => api.send('prev')} />
310          <Text> </Text>
311          <Button key="toggle" label={isPlaying ? icons.pause : icons.play} hotkey="p" plain onPress={() => api.send('toggle')} />
312          <Text> </Text>
313          <Button key="next" label={icons.next} hotkey="n" plain onPress={() => api.send('next')} />
314        </Box>
315        {q && q.tracks.length > 0 && <Text> </Text>}
316        {q && q.tracks.length > 0 && <Text dimColor>{`${icons.note} Queue · ${q.tracks.length}`}</Text>}
317        {q?.tracks.map((t, i) =>
318          i === np.index ? (
319            <Text bold wrap="truncate-end">{`${icons.play} ${i + 1}. ${t.title}`}</Text>
320          ) : (
321            <Button key={`q-${i}`} label={clip(`${i + 1}. ${t.title}`, columns)} plain dimColor onPress={() => api.play(i)} />
322          ),
323        )}
324      </Box>
325    )
326  })
327}
328
types/index.d.ts 25 lines
1// Same shape as the engine's ImageSource for a PNG file, so it goes straight into <Image source>.
2export type Art = { file: string; format: 'png' }
3
4export type NowPlaying = {
5  // cliamp's state: playing | paused | stopped
6  status: string
7  title: string
8  artist: string
9  path: string
10  // seconds; 0 when cliamp doesn't say
11  position: number
12  length: number
13  index: number
14  art: Art | null
15}
16
17export type Track = { path: string; title: string; artist: string; length: number }
18export type Queue = { revision: number; tracks: Track[] }
19
20declare module 'claude-code' {
21  interface PluginState {
22    'yt-control': { now: NowPlaying | null; queue: Queue | null; problem: string | null }
23  }
24}
25