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

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.

/config| What | Why | Install |
|---|---|---|
| Claude Code 2.1.288 or newer | runs the mod (function hooks) | claude update |
| macOS or Linux | the mod shells out to sh, curl and an image converter | |
| cliamp v2.2 or newer | plays the audio; the mod drives it over cliamp remote | see cliamp's README; cliamp upgrade if you already have it |
| yt-dlp | cliamp uses it to resolve YouTube URLs | brew install yt-dlp, pacman -S yt-dlp, pipx install yt-dlp |
curl | downloads the thumbnail | usually preinstalled |
sips (macOS, built in) or ImageMagick (Linux) | converts the thumbnail to PNG | pacman -S imagemagick, apt install imagemagick |
| A terminal with the kitty graphics protocol (kitty, Ghostty, WezTerm) | draws the thumbnail | optional; 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.
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.
/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.
[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.
Open /config and look for yt-control:
| Option | Values | Default |
|---|---|---|
| Player position | above-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 line | above-prompt |
| Icon style | emoji, nerd (needs a Nerd Font), unicode | emoji |
| Stop music on exit | stop 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 own | on |
/yt show opens the pane whatever the position is.
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.
claude plugin uninstall yt-control@cc-mods-youtube
claude plugin marketplace remove cc-mods-youtube
Thumbnails are cached in /tmp/yt-control-art/.
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.
claude --plugin-dir plugins/yt-control # load from this checkout
claude plugin validate plugins/yt-control
claude plugin test plugins/yt-control
MIT
hooks/register.tsx 328 lines1import { 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}
328types/index.d.ts 25 lines1// 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