SLOPSHOPPER

youtube

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

panecommandprocessnetworktimer
v0.1.0no licenseupdated 2026-10-15mdelapenya/claude-plugin-youtube
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · youtube
│ ┃ YouTube · experimental ✕ › fix the failing auth test and add an audit log call │ ┃ YouTube · experimental │ ┃ ⏺ Read(src/auth.ts) │ ┃ Claude is idle · auto · empty ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ▣ Video needs a terminal with Kitty graphics ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Paste a YouTube video or playlist link ⎿ 3 pass, 1 fail │ ┃ below. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Play ] [ Pause ] [ Auto ] [ Unmute ] │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [ Previous ] [ Next ] [ Reload ] [ Hide ] │ ┃ › /youtube │ ┃ YouTube link: Video or playlist URL ⏎ Load ⎿ youtube: YouTube pane opened · auto mode. Paste a video or playl │ ┃ │ ┃ Auto follows the whole turn, including │ ┃ tools. Play/Pause override Auto. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · YouTube · experimental
YouTube · experimental Claude is idle · auto · empty ▣ Video needs a terminal with Kitty graphics support. See RE Paste a YouTube video or playlist link below. [ Play ] [ Pause ] [ Auto ] [ Unmute ] [ Previous ] [ Next ] [ Reload ] [ Hide ] YouTube link: Video or playlist URL ⏎ Load Auto follows the whole turn, including tools. Play/Pause override Auto.
README

YouTube for Claude Code

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.

YouTube playlist playing in Claude Code's terminal pane, with playback controls and diagnostics

Requirements

  • macOS or Linux, with Kitty or Ghostty (Kitty graphics protocol support).
  • Claude Code with function hooks and the image API, tested on 2.1.284 and 2.1.296. These early-access APIs can change.
  • Node.js 22.13+, a recent yt-dlp with Node JavaScript-runtime support, and FFmpeg 8+. ffplay, normally included with FFmpeg, is optional for audio.
  • Network access to YouTube and its media hosts from the machine running Claude.

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.

Run it

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.

Playback

ControlBehavior
Auto (default)Plays during the main agent's turn; pauses on completion, interruption, refusal, or error. Includes tool calls, not just model reasoning.
PlayPlays even when Claude is idle. Overrides Auto until you choose Auto again. Restarts a finished video/playlist.
PauseStays paused across subsequent turns until Play or Auto is selected.
Previous / NextChanges the video within the loaded playlist.
Video selectorJumps to a playlist entry.
Mute / UnmuteEnables optional audio on the machine running Claude.
ReloadResolves the current input again after a network or playback error.
HideCloses 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.

Experimental limits

  • Video is letterboxed to 320 × 180 at 24 fps, then scaled to the pane. On local macOS, the terminal reads the current PNG directly, keeping pixel data out of Claude's hook messages. Sandbox, SSH, and Linux sessions use compressed inline PNGs. File access is probed asynchronously: pending probes are retried, including the first paused frame. Ten consecutive non-probe refusals fall back to inline PNGs; a successful paint resets the count.
  • Only the latest frame is retained, so slow drawing skips frames instead of queuing old ones. Local playback atomically replaces one private temporary PNG; helper shutdown removes it. Media is streamed, not saved to video files. Fetching continues while a paint is pending. A tiny Client redraw during playback keeps Claude's idle paint clock moving, using the technique described in the Reporails DOOM demo.
  • HLS streams are preferred because some YouTube media servers reject open-ended byte-range requests for progressive/DASH streams.
  • Audio uses a separate 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.
  • Public, accessible videos are the target. The plugin does not import cookies, read your yt-dlp configuration, or implement sign-in. Private, restricted, region-blocked, unavailable, or unsupported-format videos report an error; select Next or another link. YouTube may require a newer yt-dlp/EJS install or refuse automated access. Live streams may not resume cleanly after a long pause.
  • Playlists are limited to their first 100 entries and do not loop automatically.
  • The helper has a private Unix socket, with no listening TCP port. Session end and terminal hangup stop it and its media processes. It also checks every 250 ms whether the owning Claude process has exited. A 30-second heartbeat timeout handles a plugin that stops polling while Claude remains alive. Closing the pane keeps the paused helper available until the session ends.

Development and checks

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.

Source 5 files
hooks/register.js 431 lines
1import { 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};
431
src/youtube.js 44 lines
1import { 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}
44
src/playback-mode.js 20 lines
1/** 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}
20
hooks/heartbeat.js 17 lines
1// 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;
17
src/playlist.js 46 lines
1const 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