Experimental YouTube video rendered as terminal graphics while Claude Code works.

Watch a YouTube video or playlist inside Claude Code's terminal pane while Claude works. Paste a link into the pane, or use /youtube <link>.
This is an experimental terminal-graphics player. It uses Claude's early-access function hooks and Image/ui.blit API, yt-dlp for YouTube URLs, and FFmpeg for video frames. It does not open a browser.

ffplay, normally included with FFmpeg, is optional for audio.The Claude Desktop and VS Code surfaces do not expose the terminal Image element. An unsupported terminal shows the image's fallback text. Terminal multiplexers may also prevent graphics from reaching the terminal.
For Docker Sandboxes, use the included v3 video-processing kit. It installs the tools inside the sandbox and declares the YouTube/media network access. Run these commands on your Mac host, from this repository in Kitty or Ghostty:
sbx create --name claude-youtube-v3 \
--kit ./kits/video-processing \
-e CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 \
-e CLAUDE_CODE_NO_FLICKER=1 \
docker.io/docker/sbx-kit-claude:2.1.296 "$PWD"
sbx exec -w "$PWD" claude-youtube-v3 node src/bridge.js doctor
sbx run --name claude-youtube-v3 -- --plugin-dir "$PWD"
V3 requires a v3 workload reference; the built-in claude/codex shortcuts currently select v2. Create this new sandbox with its kit at creation time.
For running Claude directly on macOS, install the media tools with Homebrew:
brew install ffmpeg yt-dlp
On Linux, install current versions of FFmpeg and yt-dlp using your package manager or their official installers. Older distribution packages may lack required features. Keep yt-dlp updated as YouTube changes.
From this repository, check the tools and start Claude with the plugin:
npm run doctor
CLAUDE_CODE_NO_FLICKER=1 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir "$PWD"
No npm runtime dependencies or build step are needed. In Claude, enter:
/youtube
CLAUDE_CODE_NO_FLICKER=1 explicitly enables the fullscreen layout used by the DOOM demo, including on older Claude versions where it is not the default.
Paste a video or playlist URL into YouTube link, then submit Load. You can also pass the URL directly to /youtube. youtube.com/watch, youtu.be, shorts, live, embed, and playlist links are accepted. Links containing list= load the playlist, starting at the linked video when it is among the first 100 entries. Other query parameters, including timestamps, are discarded. Bare 11-character IDs are treated as videos; other valid IDs as playlists.
Claude places the pane beside the transcript in its fullscreen layout when the terminal is wide enough; otherwise it may appear above the prompt. Use the mouse or ctrl+x tab, then Tab and Enter, to operate the pane controls.
For an initial source on every session, set YouTube video or playlist under the plugin's /config options and restart. Links entered into the pane otherwise last for the current session. Start muted defaults to on.
| Control | Behavior |
|---|---|
| Auto (default) | Plays during the main agent's turn; pauses on completion, interruption, refusal, or error. Includes tool calls, not just model reasoning. |
| Play | Plays even when Claude is idle. Overrides Auto until you choose Auto again. Restarts a finished video/playlist. |
| Pause | Stays paused across subsequent turns until Play or Auto is selected. |
| Previous / Next | Changes the video within the loaded playlist. |
| Video selector | Jumps to a playlist entry. |
| Mute / Unmute | Enables optional audio on the machine running Claude. |
| Reload | Resolves the current input again after a network or playback error. |
| Hide | Closes the pane and pauses both video and audio. /youtube open brings it back. |
The same actions are available as /youtube play, pause, auto, next, previous, mute, unmute, open, and close, including during a running turn. Loading a source while Claude is idle cues its first frame; choose Play to preview it immediately. Subagent completions do not pause the main turn's video.
For the guitar-course example, enter:
/youtube https://www.youtube.com/watch?v=vnBd5kL7ynE&list=PL-RYb_OMw7Geie3oIeLVBUtJlep5Ir7UZ
If playback is slow, select Play and run /youtube stats. It samples five seconds while playback continues, then reports decoding fps, frames received by the hook, accepted paints per second, the active transport, and the longest waits. file-png is the fast local macOS path; inline-png sends the image bytes through the hook. Accepted paints are Claude acknowledgments, not measured screen refreshes. The report also includes the last paint refusal, so a file access problem can be distinguished from a slow decoder or renderer.
ffplay process and is only approximately synchronized. Separate YouTube video and audio streams are supported without merging files. It plays on the machine running Claude, not a remote SSH client. Audio errors leave muted video usable; headphones/audio routing are the host's own.npm ci
npm run lint # JavaScript/TypeScript rules and installer shell syntax
npm test # parsing, playback state, framing, socket protocol
npm run test:coverage # unit-test coverage of src/; no FFmpeg or YouTube needed
npm run types:fetch # pinned public Claude API declarations; no login needed
npm run check:hooks # typecheck hooks and native hook tests
npm run test:integration # real FFmpeg/ffplay, synthetic local media; no YouTube
claude plugin validate .
npm run test:hooks # Claude's own test harness; no model/API calls
npm run lint:fix applies ESLint's automatic fixes. Hooks are linted without Node globals, matching Claude's sandbox. The TypeScript compiler is pinned to a version supported by the TypeScript ESLint parser.
npm run types:fetch downloads the public declarations from the immutable commit behind Claude Code's v2.1.296 tag. To check your own installed API instead, run /plugin-types with the plugin loaded, then npm run check:hooks. Generated engine types are ignored by Git. Claude's harness validates the pane's element tree and input/control callbacks, but does not verify physical terminal paint.
GitHub Actions runs on pushes to main and all pull requests. It checks lint, types and unit coverage on Node 22 and 24; validates and tests the hooks on Claude Code 2.1.284 and 2.1.296; and runs real media tests with Ubuntu 26.04's FFmpeg/ffplay. Tests need no YouTube access or API credentials.
Implementation: hooks/register.js owns the pane and turn lifecycle; src/bridge.js starts the session helper; src/player.js resolves and decodes media. Pure parsers and playback-state logic are shared with the tests.
The implementation checks passed with synthetic HTTP media and a real HTTPS proxy rejection. The guitar-course URL above also loaded its 11-entry playlist, decoded real frames, and passed pause/resume and process-cleanup checks after the host approved the media domains. The user confirmed working local playback on macOS with Ghostty 1.2.2 and Claude Code 2.1.284 after the file-probe fix and fullscreen restart. See verification.
See API research for the upstream sources and API limits.
hooks/register.js 431 lines1import { parseSource } from '../src/youtube.js';
2import { PlaybackMode } from '../src/playback-mode.js';
3
4const PANE = 'youtube';
5const EMPTY_IMAGE = { rgba: 'AAAA/w==', width: 1, height: 1 };
6
7/**
8 * @typedef {import('claude-code').EngineInterface} Engine
9 * @typedef {{root: string, fetch: Engine['http']['fetch'], run: Engine['process']['run'],
10 * every: Engine['clock']['every'], invalidate: () => void, blit: Engine['ui']['blit'],
11 * openPane: Engine['ui']['open'], closePane: Engine['ui']['close']}} Host
12 * @typedef {{source: string, title: string, entries: {id: string, title: string}[],
13 * index: number, status: string, error: string, audioError: string,
14 * muted: boolean, playing: boolean}} Snapshot
15 */
16
17/** Keep engine calls here so Claude's static hook analyzer can discover them.
18 * @param {Engine} $
19 * @returns {Host}
20 */
21function hostOf($) {
22 return {
23 root: $.plugin.root,
24 fetch: (url, init) => $.http.fetch(url, init),
25 run: (argv, init) => $.process.run(argv, init),
26 every: (ms, fn) => $.clock.every(ms, fn),
27 invalidate: () => $.ui.invalidate('ui.render'),
28 blit: args => $.ui.blit(args),
29 openPane: args => $.ui.open(args),
30 closePane: args => $.ui.close(args),
31 };
32}
33
34/** @param {unknown} caught */
35const errorMessage = caught => caught instanceof Error ? caught.message : String(caught);
36
37/** @type {import('claude-code').Register} */
38export const register = (on, options) => {
39 const playback = new PlaybackMode();
40 let supported = false;
41 let socketPath = '';
42 /** @type {Promise<void> | null} */
43 let starting = null;
44 /** @type {import('claude-code').Timer | null} */
45 let timer = null;
46 let polling = false;
47 let fetchStartedAt = 0;
48 let painting = false;
49 let paintStartedAt = 0;
50 let retryPaint = false;
51 let fileRefusals = 0;
52 let lastPaintDenial = '';
53 /** @type {{frames: number, paints: number, refused: number, fetchMs: number,
54 * paintMs: number, gapMs: number, lastFrameAt: number} | null} */
55 let measurement = null;
56 let changingSource = 0;
57 let frameEpoch = 0;
58 /** @type {import('claude-code').ImageSource | null} */
59 let waitingImage = null;
60 let stopped = false;
61 let queue = Promise.resolve();
62 let sequence = -1;
63 /** @type {import('claude-code').ImageSource} */
64 let image = EMPTY_IMAGE;
65 let mounted = false;
66 let input = typeof options.source === 'string' ? options.source : '';
67 let error = '';
68 let connectionError = '';
69 let summary = '';
70 /** @type {Snapshot} */
71 let state = { source: '', title: '', entries: [], index: 0, status: 'empty',
72 error: '', audioError: '', muted: true, playing: false };
73
74 /** @param {Host} host @param {string} path @param {Record<string, unknown>} [command] */
75 async function request(host, path, command) {
76 if (!socketPath) throw new Error('Load a YouTube video or playlist first.');
77 const response = await host.fetch(`http://bridge${path}`, {
78 socketPath,
79 ...(command ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(command) } : {}),
80 });
81 const result = JSON.parse(response.text);
82 if (!response.ok) throw new Error(result.error || 'Player helper could not answer.');
83 return result;
84 }
85
86 function discardWaitingFrames() {
87 frameEpoch++;
88 waitingImage = null;
89 retryPaint = false;
90 fileRefusals = 0;
91 }
92
93 /** Discard frames fetched before a source/transport change, including late
94 * file refusals. Polling resumes once the helper has accepted the change.
95 * @param {Host} host @param {Record<string, unknown>} command @param {boolean} [clear]
96 */
97 async function changeSource(host, command, clear = true) {
98 discardWaitingFrames();
99 sequence = -1;
100 if (clear) image = EMPTY_IMAGE;
101 changingSource++;
102 try { await request(host, '/command', command); }
103 finally { changingSource--; }
104 }
105
106 /** A blit resolves at paint time. Keep fetching independently, with at most
107 * one paint outstanding and one waiting image, replaced by newer frames.
108 * @param {Host} host
109 */
110 async function paint(host) {
111 if (painting || !waitingImage || !mounted || !playback.open || stopped || !socketPath) return;
112 const source = waitingImage;
113 const epoch = frameEpoch;
114 const sample = measurement;
115 waitingImage = null;
116 retryPaint = false;
117 painting = true;
118 paintStartedAt = Date.now();
119 try {
120 const result = await host.blit({ requestId: PANE, key: 'video', source })
121 .catch(caught => ({ deny: errorMessage(caught) }));
122 if (sample && sample === measurement) {
123 sample.paintMs = Math.max(sample.paintMs, Date.now() - paintStartedAt);
124 if (result?.deny || !result) sample.refused++;
125 else sample.paints++;
126 }
127 if (epoch !== frameEpoch || stopped || !playback.open) return;
128 if (!result || result.deny) {
129 lastPaintDenial = result?.deny || 'Paint did not complete.';
130 retryPaint = true;
131 } else fileRefusals = 0;
132 // Claude probes file access asynchronously. Its initial "asked now,
133 // blit again" response is not a refusal of the file transport. A paused
134 // first frame also needs retrying, without waiting for another frame.
135 const probing = result?.deny?.includes('has not yet said whether it reads files');
136 if (result?.deny && 'file' in source && !probing && ++fileRefusals >= 10) {
137 // Serialize transport changes with controls. A hide/reload that wins
138 // the race invalidates this refusal before it can affect the new view.
139 await act(host, async () => {
140 if (epoch !== frameEpoch || !playback.open) return;
141 await changeSource(host, { action: 'inline' }, false);
142 });
143 }
144 } finally {
145 painting = false;
146 paintStartedAt = 0;
147 if (waitingImage) void paint(host);
148 }
149 }
150
151 /** @param {Host} host */
152 async function poll(host) {
153 if (polling || changingSource || !socketPath || stopped) return;
154 const epoch = frameEpoch;
155 const sample = measurement;
156 const startedAt = Date.now();
157 fetchStartedAt = startedAt;
158 polling = true;
159 try {
160 const snapshot = await request(host, `/state?after=${sequence}`);
161 if (stopped || epoch !== frameEpoch) return;
162 const { frame, sequence: nextSequence, ...nextState } = snapshot;
163 if (sample && sample === measurement) {
164 const now = Date.now();
165 sample.fetchMs = Math.max(sample.fetchMs, now - startedAt);
166 if (frame && sequence !== nextSequence) {
167 sample.frames++;
168 sample.gapMs = Math.max(sample.gapMs, now - sample.lastFrameAt);
169 sample.lastFrameAt = now;
170 }
171 }
172 if (frame) image = frame;
173 else if (sequence !== nextSequence) image = EMPTY_IMAGE;
174 const changed = sequence !== nextSequence;
175 sequence = nextSequence;
176 state = nextState;
177 connectionError = '';
178 const nextSummary = JSON.stringify(state);
179 if (nextSummary !== summary) {
180 summary = nextSummary;
181 host.invalidate();
182 }
183 if ((changed || retryPaint) && mounted && playback.open) {
184 waitingImage = image;
185 void paint(host);
186 }
187 } catch (caught) {
188 if (stopped || epoch !== frameEpoch) return;
189 connectionError = `Player connection lost. Load the link again to reconnect. ${errorMessage(caught)}`;
190 socketPath = '';
191 timer?.cancel();
192 sequence = -1;
193 discardWaitingFrames();
194 host.invalidate();
195 } finally { polling = false; fetchStartedAt = 0; }
196 }
197
198 /** @param {Host} host */
199 async function ensureBridge(host) {
200 if (socketPath) return;
201 if (!starting) {
202 starting = (async () => {
203 const result = await host.run(['node', `${host.root}/src/bridge.js`, 'start'], { timeoutMs: 10000 });
204 if (result.exitCode !== 0) throw new Error(result.stderr || 'Cannot start player helper.');
205 socketPath = JSON.parse(result.stdout).socketPath;
206 // Poll faster than the 24 fps frame period so jitter does not drop frames;
207 // a poll with no new frame returns a tiny reply.
208 timer = host.every(16, () => { void poll(host); });
209 if (stopped) {
210 timer.cancel();
211 await request(host, '/command', { action: 'shutdown' });
212 socketPath = '';
213 return;
214 }
215 if (options.muted === false) await request(host, '/command', { action: 'muted', value: false });
216 })().finally(() => { starting = null; });
217 }
218 await starting;
219 }
220
221 // UI actions and lifecycle updates preserve ordering without waiting for video lookup.
222 /** @param {Host} host @param {() => Promise<unknown>} action */
223 function act(host, action) {
224 queue = queue.then(async () => {
225 if (stopped) return;
226 try { await action(); error = ''; }
227 catch (caught) { error = errorMessage(caught); }
228 host.invalidate();
229 });
230 return queue;
231 }
232
233 /** @param {Host} host */
234 async function sync(host) {
235 if (socketPath) await request(host, '/command', { action: 'playing', value: playback.playing });
236 }
237
238 /** @param {Host} host */
239 async function open(host) {
240 await host.openPane({ id: PANE, title: 'YouTube · experimental', columns: 60, rows: 22 });
241 playback.open = true;
242 await sync(host);
243 }
244
245 /** A plugin's own close call need not traverse its own close hook.
246 * @param {Host} host
247 */
248 async function hide(host) {
249 playback.open = false;
250 mounted = false;
251 discardWaitingFrames();
252 await sync(host).catch(() => undefined);
253 await host.closePane({ id: PANE });
254 }
255
256 /** @param {Host} host @param {string} value */
257 async function load(host, value) {
258 const source = parseSource(value);
259 input = source.url;
260 await ensureBridge(host);
261 if (stopped) return;
262 await changeSource(host, { action: 'load', source: source.url });
263 await sync(host);
264 await poll(host);
265 }
266
267 on('session.start', async ($, e, next) => {
268 const host = hostOf($);
269 stopped = false;
270 supported = e.surface === 'terminal' && e.isInteractive;
271 await $.command.register({ name: 'youtube', description: 'Watch a YouTube video or playlist in a terminal pane',
272 argumentHint: '[YouTube URL|open|close|play|pause|auto|next|previous|mute|unmute|stats]', immediate: true });
273 if (supported && input) {
274 void act(host, async () => { await open(host); await load(host, input); });
275 }
276 return next(e);
277 });
278
279 on('command.run', { command: 'youtube' }, async ($, e) => {
280 const host = hostOf($);
281 if (!supported) return { text: 'YouTube video requires Claude Code in a Kitty-graphics terminal (such as Kitty or Ghostty), on macOS or Linux.' };
282 const args = e.args.trim();
283 if (args === 'stats') {
284 if (measurement) return { text: 'A playback sample is already running.' };
285 measurement = { frames: 0, paints: 0, refused: 0, fetchMs: 0, paintMs: 0, gapMs: 0, lastFrameAt: Date.now() };
286 try {
287 const before = await request(host, '/metrics');
288 if (!before.playing) return { text: 'Select Play, then run /youtube stats to measure five seconds of playback.' };
289 measurement = { frames: 0, paints: 0, refused: 0, fetchMs: 0, paintMs: 0, gapMs: 0, lastFrameAt: Date.now() };
290 const sample = measurement;
291 // This wait is outside the control queue: Hide/Pause still work.
292 await $.clock.sleep(5000);
293 const after = await request(host, '/metrics');
294 if (before.generation !== after.generation || !after.playing) {
295 return { text: 'Playback changed during the sample. Select Play and run /youtube stats again.' };
296 }
297 const seconds = Math.max(0.001, (after.now - before.now) / 1000);
298 /** @param {number} count */
299 const rate = count => (count / seconds).toFixed(1);
300 const fetchMs = Math.max(sample.fetchMs, polling ? Date.now() - fetchStartedAt : 0);
301 const paintMs = Math.max(sample.paintMs, painting ? Date.now() - paintStartedAt : 0);
302 const gapMs = Math.max(sample.gapMs, Date.now() - sample.lastFrameAt);
303 return { text: [
304 `Playback sample: ${seconds.toFixed(1)} s · target ${after.targetFps} fps`,
305 `Decoded: ${rate(after.decodedFrames - before.decodedFrames)} fps · received: ${rate(sample.frames)} fps · accepted paints: ${rate(sample.paints)}/s`,
306 `Transport: ${after.transport} · helper: ${after.platform}`,
307 `Longest wait: fetch ${fetchMs} ms · paint ${paintMs} ms · received-frame gap ${gapMs} ms`,
308 `Refused paints during sample: ${sample.refused}${lastPaintDenial ? ` · last refusal: ${lastPaintDenial}` : ''}`,
309 'Accepted paints are Claude acknowledgments, not a physical display measurement.',
310 ].join('\n') };
311 } catch (caught) { return { text: `Playback diagnostics unavailable: ${errorMessage(caught)}` }; }
312 finally { measurement = null; }
313 }
314 await act(host, async () => {
315 if (args === 'close') { await hide(host); return; }
316 await open(host);
317 if (['play', 'pause', 'auto'].includes(args)) { playback.mode = args; await sync(host); }
318 else if (args === 'next' || args === 'previous') {
319 if (!socketPath) throw new Error('Load a video or playlist first: /youtube <YouTube URL>.');
320 await changeSource(host, { action: 'select', index: state.index + (args === 'next' ? 1 : -1) });
321 } else if (args === 'mute' || args === 'unmute') {
322 await ensureBridge(host);
323 await request(host, '/command', { action: 'muted', value: args === 'mute' });
324 } else if (args && args !== 'open') await load(host, args);
325 else if (!socketPath && input) await load(host, input);
326 await poll(host);
327 });
328 return { text: error || (args === 'close' ? 'YouTube pane closed; playback paused.'
329 : `YouTube pane opened · ${playback.mode} mode. Paste a video or playlist link in the pane.`) };
330 });
331
332 on('turn.start', ($, e, next) => {
333 const host = hostOf($);
334 playback.start(e.turnId);
335 void act(host, () => sync(host));
336 return next(e);
337 });
338
339 on('turn.complete', ($, e, next) => {
340 const host = hostOf($);
341 if (e.agentId !== undefined || e.turnId !== playback.turnId) return next(e);
342 playback.complete(e);
343 void act(host, () => sync(host));
344 return next(e);
345 });
346
347 on('ui.close', { id: PANE }, async ($, e, next) => {
348 const host = hostOf($);
349 playback.open = false;
350 mounted = false;
351 discardWaitingFrames();
352 // Do not enqueue here: close can run inside a queued command.
353 await sync(host).catch(() => undefined);
354 return next(e);
355 });
356
357 on('session.end', async ($, e, next) => {
358 const host = hostOf($);
359 stopped = true;
360 discardWaitingFrames();
361 timer?.cancel();
362 await queue;
363 if (socketPath) await request(host, '/command', { action: 'shutdown' }).catch(() => undefined);
364 socketPath = '';
365 sequence = -1;
366 image = EMPTY_IMAGE;
367 playback.open = false;
368 playback.turnId = null;
369 playback.mode = 'auto';
370 mounted = false;
371 return next(e);
372 });
373
374 on('ui.render', { component: 'Pane' }, ($, e, next) => {
375 const host = hostOf($);
376 if (e.requestId !== PANE) return next(e);
377 const t = $.ui.resolve(e);
378 if (e.surface !== 'terminal' || !('Image' in t)) {
379 return t.Text({ children: 'Experimental video requires a Kitty-graphics terminal such as Kitty or Ghostty.' });
380 }
381 mounted = true;
382 const columns = Math.min(100, Math.max(1, e.props.bodyColumns - 2));
383 const rows = Math.max(1, Math.round(columns * 9 / 32));
384 /** @param {string} key @param {string} label @param {() => Promise<unknown>} action */
385 const button = (key, label, action) => t.Button({ key, label, onPress: () => { void act(host, action); } });
386 /** @param {string} value */
387 const mode = value => { playback.mode = value; return sync(host); };
388 const entry = state.entries[state.index];
389 const children = [
390 t.Text({ bold: true, children: state.title || 'YouTube · experimental' }),
391 t.Box({ flexDirection: 'row', children: [
392 t.Text({ children: `${playback.turnId ? 'Claude is working' : 'Claude is idle'} · ${playback.mode} · ${state.status}` }),
393 ...(playback.playing && state.status === 'playing' && socketPath && !stopped
394 ? [t.Client({ key: 'video-heartbeat', module: './heartbeat.js', width: 1, height: 1 })] : []),
395 ] }),
396 t.Image({ key: 'video', source: image, columns, rows,
397 alt: 'Video needs a terminal with Kitty graphics support. See README for setup.' }),
398 t.Text({ children: entry ? `${state.index + 1}/${state.entries.length} · ${entry.title}` : 'Paste a YouTube video or playlist link below.' }),
399 t.Box({ flexDirection: 'row', gap: 1, children: [
400 button('play', 'Play', () => mode('play')),
401 button('pause', 'Pause', () => mode('pause')),
402 button('auto', 'Auto', () => mode('auto')),
403 button('mute', state.muted ? 'Unmute' : 'Mute', async () => {
404 await ensureBridge(host);
405 await request(host, '/command', { action: 'muted', value: !state.muted });
406 await poll(host);
407 }),
408 ] }),
409 t.Box({ flexDirection: 'row', gap: 1, children: [
410 button('previous', 'Previous', () => changeSource(host, { action: 'select', index: state.index - 1 })),
411 button('next', 'Next', () => changeSource(host, { action: 'select', index: state.index + 1 })),
412 button('retry', 'Reload', () => load(host, input)),
413 button('hide', 'Hide', () => hide(host)),
414 ] }),
415 t.Input({ key: 'source', label: 'YouTube link', value: input, placeholder: 'Video or playlist URL',
416 submitLabel: 'Load', onInput: value => { input = value; },
417 onSubmit: value => { void act(host, () => load(host, value)); } }),
418 ];
419 if (state.entries.length) children.push(t.Select({ key: 'video-select', label: 'Video',
420 value: String(state.index), options: state.entries.map((item, index) => ({ value: String(index), label: `${index + 1}. ${item.title}` })),
421 onSelect: value => { void act(host, () => changeSource(host, { action: 'select', index: Number(value) })); },
422 }));
423 if (error || connectionError || state.error || state.audioError) {
424 children.push(t.Text({ children: error || connectionError || state.error || state.audioError }));
425 }
426 children.push(t.Text({ dimColor: true, children: 'Auto follows the whole turn, including tools. Play/Pause override Auto.' }));
427 return t.Box({ flexDirection: 'column', gap: 1, children });
428 });
429 return undefined;
430};
431src/youtube.js 44 lines1import { parsePlaylist, playlistUrl } from './playlist.js';
2
3const VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
4const HOSTS = new Set(['youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com', 'youtu.be']);
5
6/**
7 * Normalize a video or playlist. A list= link is a playlist even when it names
8 * a video too; startVideo preserves that selection without forwarding tracking.
9 * @param {unknown} input
10 * @returns {{kind: 'video' | 'playlist', id: string, url: string, startVideo?: string}}
11 */
12export function parseSource(input) {
13 if (typeof input !== 'string' || !input.trim()) throw new Error('Enter a YouTube video or playlist link.');
14 const value = input.trim();
15 if (VIDEO_ID.test(value)) return { kind: 'video', id: value, url: `https://www.youtube.com/watch?v=${value}` };
16 if (/^[A-Za-z0-9_-]{10,150}$/.test(value)) {
17 return { kind: 'playlist', id: parsePlaylist(value), url: playlistUrl(value) };
18 }
19 let url;
20 try { url = new URL(value); } catch { throw new Error('Enter a complete YouTube video or playlist URL.'); }
21 if (!['https:', 'http:'].includes(url.protocol) || !HOSTS.has(url.hostname) || url.username || url.password || url.port) {
22 throw new Error('Use a youtube.com or youtu.be video or playlist URL.');
23 }
24 const parts = url.pathname.split('/').filter(Boolean);
25 let video = '';
26 if (url.hostname === 'youtu.be' && parts.length === 1) video = parts[0];
27 else if (['shorts', 'live', 'embed'].includes(parts[0]) && parts.length === 2) video = parts[1];
28 else if (url.pathname === '/watch') {
29 const videos = url.searchParams.getAll('v');
30 if (videos.length > 1) throw new Error('Use a link with only one video ID.');
31 video = videos[0] ?? '';
32 }
33 if (video && !VIDEO_ID.test(video)) throw new Error('The YouTube video ID is invalid.');
34 if (url.searchParams.getAll('list').length) {
35 const id = parsePlaylist(value);
36 return { kind: 'playlist', id,
37 url: video ? `https://www.youtube.com/watch?v=${video}&list=${id}` : playlistUrl(id),
38 ...(video ? { startVideo: video } : {}),
39 };
40 }
41 if (!video) throw new Error('This link does not identify a YouTube video or playlist.');
42 return { kind: 'video', id: video, url: `https://www.youtube.com/watch?v=${video}` };
43}
44src/playback-mode.js 20 lines1/** Main-turn identity prevents subagent and stale completion events pausing video. */
2export class PlaybackMode {
3 mode = 'auto';
4 /** @type {string | null} */
5 turnId = null;
6 open = false;
7
8 /** @param {string} turnId */
9 start(turnId) { this.turnId = turnId; }
10
11 /** @param {{turnId: string, agentId?: string}} event */
12 complete(event) {
13 if (event.agentId === undefined && event.turnId === this.turnId) this.turnId = null;
14 }
15
16 get playing() {
17 return this.open && (this.mode === 'play' || (this.mode === 'auto' && this.turnId !== null));
18 }
19}
20hooks/heartbeat.js 17 lines1// Keep Claude's idle paint clock moving without invalidating the whole pane.
2// Technique described by Gábor Mészáros (Reporails), "Claude Code mods: but
3// does it run Doom?": https://dev.to/reporails/claude-code-mods-but-does-it-run-doom-2mma
4// This Client exists only during playback; unmounting cancels its timer.
5
6/** @type {import('claude-code').ClientModule<null, boolean>} */
7const Heartbeat = (_props, surface) => {
8 if (surface.state === undefined) {
9 surface.setState(false);
10 surface.every(30, () => surface.setState(!surface.state));
11 }
12 // Different glyphs, both visually blank, so the renderer sees a change.
13 return surface.elements.Text({ children: surface.state ? '\u2800' : ' ' });
14};
15
16export default Heartbeat;
17src/playlist.js 46 lines1const PLAYLIST_ID = /^[A-Za-z0-9_-]{10,150}$/;
2const YOUTUBE_HOSTS = new Set([
3 'youtube.com',
4 'www.youtube.com',
5 'm.youtube.com',
6 'music.youtube.com',
7 'youtu.be',
8]);
9
10/**
11 * Accept a playlist ID or an explicit YouTube URL containing one.
12 * Return only the ID: tracking parameters and arbitrary hosts never reach a player.
13 * This validates syntax; availability is determined by YouTube at playback time.
14 * @param {unknown} input
15 */
16export function parsePlaylist(input) {
17 if (typeof input !== 'string') throw new Error('Enter a YouTube playlist URL or ID.');
18 const value = input.trim();
19 if (!value) throw new Error('Enter a YouTube playlist URL or ID.');
20 if (PLAYLIST_ID.test(value)) return value;
21
22 let url;
23 try {
24 url = new URL(value);
25 } catch {
26 throw new Error('Enter a complete YouTube playlist URL or a playlist ID.');
27 }
28 if (
29 !['https:', 'http:'].includes(url.protocol) ||
30 !YOUTUBE_HOSTS.has(url.hostname) ||
31 url.username || url.password || url.port
32 ) {
33 throw new Error('Use a youtube.com or youtu.be playlist URL.');
34 }
35 const ids = url.searchParams.getAll('list');
36 if (ids.length !== 1 || !PLAYLIST_ID.test(ids[0])) {
37 throw new Error('The YouTube URL must contain one valid list= playlist ID.');
38 }
39 return ids[0];
40}
41
42/** Produce a canonical public link from validated input. @param {unknown} input */
43export function playlistUrl(input) {
44 return `https://www.youtube.com/playlist?list=${parsePlaylist(input)}`;
45}
46