SLOPSHOPPER

youtube-side-player

Watch YouTube videos on the side in any screen or terminal position while working in Claude Code

newpanebandspinnerguardcommand
★ 1v0.1.0MITupdated 2026-10-05hemanth/claude-code-youtube-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · youtube-side-player
│ ┃ YouTube Side Player ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Player 2: Position (top-right) 3: Pla │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ ·············██▶██· ○ READY · TOP-RIGHT · ⏺ Update(src/auth.ts) │ ┃ │ · █████· lofi hip hop radio - ⎿ Added 2 lines, removed 1 line │ ┃ │ · · relax/study to ⏺ Bash(bun test) │ ┃ │ · · Channel: Lofi Girl · ⎿ 3 pass, 1 fail │ ┃ │ ··················· Open on YouTube https │ ┃ ╰─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Inline · Ghostty: Ghostty draws inline imag… ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Play / Search: Paste YouTube URL, video ID, › /yt │ ┃ │ ┃ [ Play PiP [p] ] [ Mute [m] ] [ Popout Sid │ ┃ │ ┃ Quick Corner & Side Snap (or press 2 for │ ┃ full 9-point grid): │ ┃ │ ┃ ↖ Top-Left ↗ Top-Right ↙ Bottom-Left ↘ Bo │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · YouTube Side Player
1: Player 2: Position (top-right) 3: Playlist (6) 4: N ╭─────────────────────────────────────────────────────────── │ ·············██▶██· ○ READY · TOP-RIGHT · MEDIUM │ · █████· lofi hip hop radio - beats to relax/s │ · · Channel: Lofi Girl · ID: jfKfPfyJRdk │ · · Open on YouTube https://www.youtube.c │ ··················· ╰─────────────────────────────────────────────────────────── Inline · Ghostty: Ghostty draws inline images Play / Search: Paste YouTube URL, video ID, or search query [ Play PiP [p] ] [ Mute [m] ] [ Popout Side Window [o] ] Quick Corner & Side Snap (or press 2 for full 9-point grid): ↖ Top-Left ↗ Top-Right ↙ Bottom-Left ↘ Bottom-Right ⇥ Ri
README

claude-code-youtube-mod

Watch YouTube right inside Claude Code, while you code.

claude-code-youtube-mod

In Ghostty or kitty the video plays inline, in Claude Code's side pane, with sound. Everywhere else it pops out into a floating, always-on-top macOS window.

Install

/plugin marketplace add hemanth/claude-code-youtube-mod
/plugin install youtube-side-player@claude-code-youtube-mod

__Or from a clone:__

$ git clone https://github.com/hemanth/claude-code-youtube-mod
$ claude --plugin-dir ./claude-code-youtube-mod

Needs Claude Code 2.1.287+ and macOS.

Usage

/yt marcus aurelius meditations animated

That's it: it searches YouTube and plays the top hit.

__More:__

/yt                          open the player pane
/yt <url | id | search>      play
/yt pause | resume
/yt stop                     stop and close the pane
/yt hide | show              hide the UI, keep listening
/yt pos <where> [size]       move the popout window (top-right, left-side, x,y,w,h ...)
/yt width <cols>             pane width
/yt mode auto|inline|window
/yt setup                    install the inline video tools
/yt status

Or just ask Claude: "play some lofi on the side".

Inline or popout?

Decided per session, from the terminal:

  1. Ghostty or kitty, not inside tmux: __inline__, in the side pane.
  2. Anything else, including the desktop app: __popout__ window.

/yt status tells you which one, and why.

Dependencies

Inline video needs yt-dlp and ffmpeg. If they're on your PATH, they're used. If not, the first /yt play fetches them into ~/.claude/plugins/data/youtube-side-player/bin/:

  • checksum-verified (SHA-256), never system-wide, no Homebrew.
  • yt-dlp updates itself weekly, as YouTube keeps changing.

Delete that folder to remove them.

Development

$ claude plugin validate --strict .
$ claude plugin test .
$ swiftc -O native/YTPiPPlayer.swift -o bin/yt-pip -framework Cocoa -framework WebKit

License

MIT © Hemanth.HM

Source 1 files
hooks/register.js 1638 lines
1const PANE_ID = 'youtube-side-player';
2const STATE_FILE_PATH = '/tmp/claude-yt-mod-state.json';
3const DEFAULT_COLOR = 0x01000000;
4
5const CURATED_VIDEOS = [
6  {
7    id: 'jfKfPfyJRdk',
8    title: 'lofi hip hop radio - beats to relax/study to',
9    author: 'Lofi Girl',
10    category: 'Focus & Beats'
11  },
12  {
13    id: 'zjkBMFhNj_g',
14    title: 'Intro to Large Language Models (1hr Talk)',
15    author: 'Andrej Karpathy',
16    category: 'AI & Systems'
17  },
18  {
19    id: 'aircAruvnKk',
20    title: 'But what is a neural network? | Deep learning Ch. 1',
21    author: '3Blue1Brown',
22    category: 'Math & ML'
23  },
24  {
25    id: '4b4MUYve_U8',
26    title: 'Synthwave Radio - chill synth / retro beats to code to',
27    author: 'Lofi Girl Synthwave',
28    category: 'Focus & Beats'
29  },
30  {
31    id: 'kCc8FmEb1nY',
32    title: 'Let’s build GPT: from scratch, in code, spelled out',
33    author: 'Andrej Karpathy',
34    category: 'AI & Systems'
35  },
36  {
37    id: 'r6sGWTCMz2k',
38    title: 'But what is a Fourier series? From heat flow to circles',
39    author: '3Blue1Brown',
40    category: 'Math & ML'
41  }
42];
43
44const POSITION_PRESETS = [
45  { key: 'top-left', short: 'TL', arrow: '↖', label: 'Top-Left', hotkey: 'q', row: 0, col: 0 },
46  { key: 'top-center', short: 'TC', arrow: '↑', label: 'Top-Center', hotkey: 'w', row: 0, col: 1 },
47  { key: 'top-right', short: 'TR', arrow: '↗', label: 'Top-Right', hotkey: 'e', row: 0, col: 2 },
48  { key: 'left-side', short: 'L', arrow: '←', label: 'Left-Side', hotkey: 'a', row: 1, col: 0 },
49  { key: 'center', short: 'C', arrow: '·', label: 'Center', hotkey: 's', row: 1, col: 1 },
50  { key: 'right-side', short: 'R', arrow: '→', label: 'Right-Side', hotkey: 'd', row: 1, col: 2 },
51  { key: 'bottom-left', short: 'BL', arrow: '↙', label: 'Bottom-Left', hotkey: 'z', row: 2, col: 0 },
52  { key: 'bottom-center', short: 'BC', arrow: '↓', label: 'Bottom-Center', hotkey: 'x', row: 2, col: 1 },
53  { key: 'bottom-right', short: 'BR', arrow: '↘', label: 'Bottom-Right', hotkey: 'c', row: 2, col: 2 }
54];
55
56let activeTab = 'player';
57let currentVideo = {
58  id: CURATED_VIDEOS[0].id,
59  title: CURATED_VIDEOS[0].title,
60  author: CURATED_VIDEOS[0].author
61};
62let screenPosition = 'top-right';
63let customCoords = '';
64let sizePreset = 'medium';
65let opacity = 0.96;
66let uiPlacement = 'both'; // 'pane' | 'band' | 'both' | 'spinner'
67let paneColumns = 58;
68let isPlaying = false;
69let isMuted = false;
70let playlist = CURATED_VIDEOS.slice();
71let searchResults = [];
72let videoNotes = [];
73let aiSummary = '';
74let pluginRootPath = '';
75let isUiHidden = false;
76let hasActiveVideo = false; // a video is loaded (playing or paused) until stopped
77// Inline (in-pane) playback: decided per session from the terminal
78const INLINE_FRAME = { width: 1280, height: 720, fps: 24, file: '/tmp/claude-yt-inline-frame.rgb' };
79const INLINE_IMAGE_KEY = 'inline-video';
80const INLINE_CACHE_DIR = '/tmp/claude-yt-inline-cache';
81let modePreference = 'auto'; // 'auto' | 'inline' | 'window'
82let playbackMode = 'window'; // 'inline' | 'window'
83let terminalInfo = { canInline: false, terminal: 'unknown', reason: 'not detected yet' };
84let inlineSession = null;
85let inlineOffset = 0;
86let depsDir = ''; // where bin/install-deps.sh puts yt-dlp and ffmpeg
87let depsInstall = null; // the install in flight, if any
88let toolPaths = { 'yt-dlp': 'yt-dlp', ffmpeg: 'ffmpeg' };
89let lastSurface = null;
90const DEPS_UPDATE_EVERY_MS = 7 * 24 * 60 * 60 * 1000;
91
92export function extractYouTubeId(input) {
93  const raw = String(input || '').trim();
94  if (!raw) return null;
95  if (/^[A-Za-z0-9_-]{11}$/.test(raw)) {
96    return raw;
97  }
98  try {
99    const url = new URL(raw);
100    const host = url.hostname.toLowerCase();
101    if (host.includes('youtu.be')) {
102      const id = url.pathname.replace(/^\/+/, '').slice(0, 11);
103      if (/^[A-Za-z0-9_-]{11}$/.test(id)) return id;
104    }
105    if (host.includes('youtube.com') || host.includes('youtube-nocookie.com')) {
106      const v = url.searchParams.get('v');
107      if (v && /^[A-Za-z0-9_-]{11}$/.test(v.slice(0, 11))) {
108        return v.slice(0, 11);
109      }
110      const parts = url.pathname.split('/').filter(Boolean);
111      const markerIdx = parts.findIndex((p) => p === 'embed' || p === 'shorts' || p === 'live' || p === 'v');
112      if (markerIdx !== -1 && parts[markerIdx + 1]) {
113        const candidate = parts[markerIdx + 1].slice(0, 11);
114        if (/^[A-Za-z0-9_-]{11}$/.test(candidate)) return candidate;
115      }
116    }
117  } catch {
118    // Not a full URL; check for inline v= or youtu.be/ pattern
119  }
120  const match = raw.match(/(?:v=|youtu\.be\/|embed\/|shorts\/)([A-Za-z0-9_-]{11})/);
121  return match ? match[1] : null;
122}
123
124export function normalizePosition(raw) {
125  const cleaned = String(raw || '').trim().toLowerCase();
126  if (!cleaned) return 'top-right';
127  const map = {
128    tr: 'top-right',
129    'top-right': 'top-right',
130    topright: 'top-right',
131    tl: 'top-left',
132    'top-left': 'top-left',
133    topleft: 'top-left',
134    br: 'bottom-right',
135    'bottom-right': 'bottom-right',
136    bottomright: 'bottom-right',
137    bl: 'bottom-left',
138    'bottom-left': 'bottom-left',
139    bottomleft: 'bottom-left',
140    r: 'right-side',
141    right: 'right-side',
142    'right-side': 'right-side',
143    'dock-right': 'right-side',
144    l: 'left-side',
145    left: 'left-side',
146    'left-side': 'left-side',
147    'dock-left': 'left-side',
148    tc: 'top-center',
149    top: 'top-center',
150    'top-center': 'top-center',
151    bc: 'bottom-center',
152    bottom: 'bottom-center',
153    'bottom-center': 'bottom-center',
154    c: 'center',
155    center: 'center'
156  };
157  if (map[cleaned]) return map[cleaned];
158  if (/^\d+\s*,\s*\d+/.test(cleaned)) {
159    return 'custom';
160  }
161  return cleaned;
162}
163
164function packRasterCells(rows) {
165  const numbers = rows.flat().flatMap(([ch, fg, bg]) => [
166    ch.codePointAt(0),
167    fg ?? DEFAULT_COLOR,
168    bg ?? DEFAULT_COLOR
169  ]);
170  return new Uint8Array(Uint32Array.from(numbers).buffer).toBase64();
171}
172
173function buildScreenRadarRaster(pos, playing) {
174  const cols = 19;
175  const rowsCount = 5;
176  const activePos = normalizePosition(pos);
177  const grid = [];
178
179  let targetRow = 0;
180  let targetColStart = 13;
181  let targetRowEnd = 1;
182
183  if (activePos === 'top-left') {
184    targetRow = 0;
185    targetRowEnd = 1;
186    targetColStart = 1;
187  } else if (activePos === 'top-center') {
188    targetRow = 0;
189    targetRowEnd = 1;
190    targetColStart = 7;
191  } else if (activePos === 'top-right') {
192    targetRow = 0;
193    targetRowEnd = 1;
194    targetColStart = 13;
195  } else if (activePos === 'left-side') {
196    targetRow = 1;
197    targetRowEnd = 3;
198    targetColStart = 1;
199  } else if (activePos === 'center' || activePos === 'custom') {
200    targetRow = 1;
201    targetRowEnd = 3;
202    targetColStart = 7;
203  } else if (activePos === 'right-side') {
204    targetRow = 1;
205    targetRowEnd = 3;
206    targetColStart = 13;
207  } else if (activePos === 'bottom-left') {
208    targetRow = 3;
209    targetRowEnd = 4;
210    targetColStart = 1;
211  } else if (activePos === 'bottom-center') {
212    targetRow = 3;
213    targetRowEnd = 4;
214    targetColStart = 7;
215  } else if (activePos === 'bottom-right') {
216    targetRow = 3;
217    targetRowEnd = 4;
218    targetColStart = 13;
219  }
220
221  const activeColor = playing ? 0xe53935 : 0x29b6f6;
222  const frameColor = 0x546e7a;
223
224  for (let r = 0; r < rowsCount; r += 1) {
225    const rowCells = [];
226    for (let c = 0; c < cols; c += 1) {
227      const inTarget =
228        r >= targetRow &&
229        r <= targetRowEnd &&
230        c >= targetColStart &&
231        c <= targetColStart + 4;
232      if (inTarget) {
233        const ch = c === targetColStart + 2 && r === targetRow ? '▶' : '█';
234        rowCells.push([ch, 0xffffff, activeColor]);
235      } else if (c === 0 || c === cols - 1 || r === 0 || r === rowsCount - 1) {
236        rowCells.push(['·', frameColor, DEFAULT_COLOR]);
237      } else {
238        rowCells.push([' ', DEFAULT_COLOR, DEFAULT_COLOR]);
239      }
240    }
241    grid.push(rowCells);
242  }
243  return { columns: cols, rows: rowsCount, cells: packRasterCells(grid) };
244}
245
246function buildDesktopPositionSvg(pos, playing) {
247  const activePos = normalizePosition(pos);
248  const coords = {
249    'top-left': { x: 8, y: 6, w: 38, h: 20 },
250    'top-center': { x: 51, y: 6, w: 38, h: 20 },
251    'top-right': { x: 94, y: 6, w: 38, h: 20 },
252    'left-side': { x: 8, y: 14, w: 34, h: 44 },
253    center: { x: 51, y: 24, w: 38, h: 24 },
254    custom: { x: 51, y: 24, w: 38, h: 24 },
255    'right-side': { x: 98, y: 14, w: 34, h: 44 },
256    'bottom-left': { x: 8, y: 46, w: 38, h: 20 },
257    'bottom-center': { x: 51, y: 46, w: 38, h: 20 },
258    'bottom-right': { x: 94, y: 46, w: 38, h: 20 }
259  };
260  const box = coords[activePos] || coords['top-right'];
261  const fill = playing ? '#e53935' : '#29b6f6';
262  return (
263    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 72">' +
264    '<rect x="2" y="2" width="136" height="68" rx="6" fill="#111827" stroke="#374151" stroke-width="1.5"/>' +
265    '<rect x="' + box.x + '" y="' + box.y + '" width="' + box.w + '" height="' + box.h + '" rx="3" fill="' + fill + '"/>' +
266    '<polygon points="' + (box.x + 15) + ',' + (box.y + 5) + ' ' + (box.x + 15) + ',' + (box.y + box.h - 5) + ' ' + (box.x + 25) + ',' + (box.y + box.h / 2) + '" fill="#ffffff"/>' +
267    '</svg>'
268  );
269}
270
271async function persistSettings($) {
272  await $.store.set('currentVideo', currentVideo);
273  await $.store.set('screenPosition', screenPosition);
274  await $.store.set('customCoords', customCoords);
275  await $.store.set('sizePreset', sizePreset);
276  await $.store.set('opacity', opacity);
277  await $.store.set('uiPlacement', uiPlacement);
278  await $.store.set('paneColumns', paneColumns);
279  await $.store.set('playlist', playlist);
280  await $.store.set('videoNotes', videoNotes);
281}
282
283async function loadSavedSettings($) {
284  const savedVideo = await $.store.get('currentVideo');
285  if (savedVideo && typeof savedVideo === 'object' && savedVideo.id) {
286    currentVideo = savedVideo;
287  }
288  const savedPos = await $.store.get('screenPosition');
289  if (typeof savedPos === 'string' && savedPos) {
290    screenPosition = savedPos;
291  }
292  const savedCustom = await $.store.get('customCoords');
293  if (typeof savedCustom === 'string') {
294    customCoords = savedCustom;
295  }
296  const savedSize = await $.store.get('sizePreset');
297  if (typeof savedSize === 'string' && savedSize) {
298    sizePreset = savedSize;
299  }
300  const savedOpacity = await $.store.get('opacity');
301  if (typeof savedOpacity === 'number') {
302    opacity = savedOpacity;
303  }
304  const savedPlacement = await $.store.get('uiPlacement');
305  if (typeof savedPlacement === 'string' && savedPlacement) {
306    uiPlacement = savedPlacement;
307  }
308  const savedCols = await $.store.get('paneColumns');
309  if (typeof savedCols === 'number') {
310    paneColumns = savedCols;
311  }
312  const savedPlaylist = await $.store.get('playlist');
313  if (Array.isArray(savedPlaylist) && savedPlaylist.length > 0) {
314    playlist = savedPlaylist;
315  }
316  const savedNotes = await $.store.get('videoNotes');
317  if (Array.isArray(savedNotes)) {
318    videoNotes = savedNotes;
319  }
320  const savedMode = await $.store.get('modePreference');
321  if (savedMode === 'auto' || savedMode === 'inline' || savedMode === 'window') {
322    modePreference = savedMode;
323  }
324}
325
326async function resolveVideoMetadata($, videoId, fallbackTitle) {
327  const oembedUrl =
328    'https://www.youtube.com/oembed?url=' +
329    encodeURIComponent('https://www.youtube.com/watch?v=' + videoId) +
330    '&format=json';
331  try {
332    const res = await $.http.fetch(oembedUrl);
333    if (res && res.ok && res.text) {
334      const data = JSON.parse(res.text);
335      return {
336        id: videoId,
337        title: data.title || fallbackTitle || 'YouTube Video (' + videoId + ')',
338        author: data.author_name || 'YouTube'
339      };
340    }
341  } catch {
342    // Fall back to curated or default title when offline or in tests
343  }
344  const curated = CURATED_VIDEOS.find((v) => v.id === videoId);
345  if (curated) {
346    return { id: curated.id, title: curated.title, author: curated.author };
347  }
348  return {
349    id: videoId,
350    title: fallbackTitle || 'YouTube Video (' + videoId + ')',
351    author: 'YouTube'
352  };
353}
354
355async function searchYouTubeVideos($, query) {
356  const q = String(query || '').trim();
357  if (!q) return [];
358  const lower = q.toLowerCase();
359  const localMatches = playlist.filter(
360    (v) =>
361      v.title.toLowerCase().includes(lower) ||
362      (v.author && v.author.toLowerCase().includes(lower)) ||
363      (v.category && v.category.toLowerCase().includes(lower))
364  );
365
366  try {
367    const searchUrl = 'https://www.youtube.com/results?search_query=' + encodeURIComponent(q);
368    const res = await $.http.fetch(searchUrl);
369    if (res && res.ok && res.text) {
370      const results = [];
371      const seen = new Set();
372      const regex = /"videoId":"([A-Za-z0-9_-]{11})".*?"title":\{"runs":\[\{"text":"([^"]+)"/g;
373      let match = regex.exec(res.text);
374      while (match && results.length < 6) {
375        const id = match[1];
376        let title = match[2];
377        try {
378          // Titles are JSON string contents: decode \u0026 and friends
379          title = JSON.parse('"' + title + '"');
380        } catch {
381          // Keep it as found
382        }
383        if (!seen.has(id)) {
384          seen.add(id);
385          results.push({ id, title, author: 'YouTube Search' });
386        }
387        match = regex.exec(res.text);
388      }
389      if (results.length > 0) {
390        return results;
391      }
392    }
393  } catch {
394    // Fall back to local curated matches
395  }
396
397  return localMatches.length > 0 ? localMatches : [CURATED_VIDEOS[0]];
398}
399
400async function runNativePipCommand($, args) {
401  const binPath = pluginRootPath + '/bin/yt-pip';
402  try {
403    await $.process.run([binPath, ...args]);
404    return true;
405  } catch {
406    return false;
407  }
408}
409
410// Terminals whose graphics protocol the pane's `Image` element draws (kitty's).
411const INLINE_TERMINALS = [
412  { name: 'Ghostty', test: (env) => /ghostty/i.test(env.termProgram) || /ghostty/i.test(env.term) || !!env.ghostty },
413  { name: 'kitty', test: (env) => /kitty/i.test(env.term) || !!env.kittyWindowId }
414];
415
416export function classifyTerminal(env) {
417  if (env.surface && env.surface !== 'terminal') {
418    return { canInline: false, terminal: env.surface, reason: 'the ' + env.surface + ' app draws no video in a pane' };
419  }
420  if (env.tmux) {
421    return { canInline: false, terminal: 'tmux', reason: 'tmux does not pass inline images through' };
422  }
423  const match = INLINE_TERMINALS.find((t) => t.test(env));
424  if (match) {
425    return { canInline: true, terminal: match.name, reason: match.name + ' draws inline images' };
426  }
427  const name = env.termProgram || env.term || 'this terminal';
428  return { canInline: false, terminal: name, reason: name + ' has no inline image protocol (Ghostty or kitty do)' };
429}
430
431// A tool on PATH wins; otherwise the copy bin/install-deps.sh put in depsDir.
432async function resolveTool($, name) {
433  try {
434    const { exitCode, stdout } = await $.process.run(['/usr/bin/env', 'which', name]);
435    if (exitCode === 0 && stdout.trim()) return stdout.trim();
436  } catch {
437    // Not on PATH
438  }
439  if (depsDir) {
440    try {
441      const { exitCode } = await $.process.run(['/bin/test', '-x', depsDir + '/' + name]);
442      if (exitCode === 0) return depsDir + '/' + name;
443    } catch {
444      // Not installed by us either
445    }
446  }
447  return null;
448}
449
450// Runs bin/install-deps.sh: yt-dlp and ffmpeg into depsDir, checksum-verified.
451async function installDeps($, { update = false } = {}) {
452  if (depsInstall) return depsInstall;
453  depsInstall = (async () => {
454    try {
455      const { exitCode, stdout, stderr } = await $.process.run(
456        ['/bin/bash', pluginRootPath + '/bin/install-deps.sh', ...(update ? ['--update'] : [])],
457        { env: { YT_MOD_DEPS_DIR: depsDir }, timeoutMs: 600000 }
458      );
459      const lines = String(stdout || '').trim().split('\n');
460      const last = lines[lines.length - 1] || '';
461      if (exitCode !== 0 || !last.startsWith('ok ')) {
462        const why = lines.find((l) => l.startsWith('error ')) || String(stderr || '').trim().split('\n').pop() || 'exit ' + exitCode;
463        return { ok: false, message: why.replace(/^error /, '') };
464      }
465      await $.store.set('depsCheckedAt', Date.now());
466      return { ok: true, message: 'yt-dlp and ffmpeg are ready in ' + depsDir };
467    } catch (err) {
468      return { ok: false, message: err && err.message ? err.message : String(err) };
469    } finally {
470      depsInstall = null;
471    }
472  })();
473  return depsInstall;
474}
475
476// Installs missing tools, then decides the playback mode again.
477async function setupInlineDeps($) {
478  $.ui.toast('Installing yt-dlp + ffmpeg for inline video (one-time, ~100 MB)…');
479  const result = await installDeps($);
480  await detectPlaybackMode($, lastSurface);
481  $.ui.toast(result.ok ? 'Inline video ready: ' + describeMode() : 'Install failed: ' + result.message);
482  $.ui.invalidate('ui.render');
483  return result;
484}
485
486async function detectPlaybackMode($, surface) {
487  const env = {
488    surface,
489    termProgram: (await $.env.get('TERM_PROGRAM')) || '',
490    term: (await $.env.get('TERM')) || '',
491    tmux: (await $.env.get('TMUX')) || '',
492    kittyWindowId: (await $.env.get('KITTY_WINDOW_ID')) || '',
493    ghostty: (await $.env.get('GHOSTTY_RESOURCES_DIR')) || ''
494  };
495  lastSurface = surface;
496  let result = { ...classifyTerminal(env), needsDeps: false };
497  if (result.canInline) {
498    const missing = [];
499    for (const tool of ['yt-dlp', 'ffmpeg']) {
500      const path = await resolveTool($, tool);
501      if (path) toolPaths[tool] = path;
502      else missing.push(tool);
503    }
504    if (missing.length > 0) {
505      result = {
506        ...result,
507        canInline: false,
508        needsDeps: true,
509        reason: 'needs ' + missing.join(' + ') + ' (installed on first play, or /yt setup)'
510      };
511    }
512  }
513  terminalInfo = result;
514  applyModePreference();
515  return result;
516}
517
518function applyModePreference() {
519  playbackMode = modePreference === 'window' || !terminalInfo.canInline ? 'window' : 'inline';
520}
521
522function inlinePosition() {
523  if (!inlineSession) return inlineOffset;
524  return inlineSession.offset + (Date.now() - inlineSession.startedAt) / 1000;
525}
526
527function drainInBackground(stream, onEnd) {
528  void (async () => {
529    let stderr = '';
530    let code = null;
531    try {
532      while (true) {
533        const step = await stream.next();
534        if (step.done) {
535          code = step.value ? step.value.code : null;
536          break;
537        }
538        if (step.value.stream === 'stderr' && stderr.length < 2000) stderr += step.value.text;
539      }
540    } catch {
541      // Closed by stopInline
542    }
543    if (onEnd) onEnd(code, stderr);
544  })();
545}
546
547function stopInline() {
548  if (!inlineSession) return;
549  const session = inlineSession;
550  inlineOffset = inlinePosition();
551  inlineSession = null;
552  session.frameTimer.cancel();
553  session.proc.return(undefined).catch(() => {});
554}
555
556function inlineFrameSource(generation) {
557  return {
558    file: INLINE_FRAME.file,
559    format: 'rgb',
560    width: INLINE_FRAME.width,
561    height: INLINE_FRAME.height,
562    generation
563  };
564}
565
566// One ffmpeg reads yt-dlp's separate video and audio downloads at the same
567// pace, writing frames for the pane and playing the sound, so they stay in
568// sync. `exec` makes the spawned process ffmpeg itself: ending it ends both
569// downloads (they get a broken pipe).
570const shellQuote = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
571
572export function buildInlineScript(videoId, offsetSeconds, muted, tools = { 'yt-dlp': 'yt-dlp', ffmpeg: 'ffmpeg' }) {
573  if (!/^[A-Za-z0-9_-]{11}$/.test(videoId)) {
574    throw new Error('not a YouTube video id: ' + videoId);
575  }
576  const seek = String(Math.max(0, Math.floor(offsetSeconds)));
577  // Read the first `seek` seconds at full speed so resuming does not wait
578  const burst = String(Math.max(0, Math.floor(offsetSeconds)) + 1);
579  const download =
580    shellQuote(tools['yt-dlp']) + ' -q --no-warnings --no-part --cache-dir ' + INLINE_CACHE_DIR + ' -o - ' +
581    '"https://www.youtube.com/watch?v=' + videoId + '"';
582  return [
583    'exec ' + shellQuote(tools.ffmpeg) + ' -loglevel error',
584    '-readrate 1 -readrate_initial_burst ' + burst,
585    '-i <(' + download + ' -f "bv*[height<=1080][vcodec^=avc1]/bv*[height<=1080]/bv*")',
586    '-readrate 1 -readrate_initial_burst ' + burst,
587    '-i <(' + download + ' -f "ba[ext=m4a]/ba")',
588    '-ss ' + seek + ' -map 0:v:0',
589    // Fit inside the frame and letterbox, so 4:3 and vertical videos keep their shape
590    "-vf 'scale=" + INLINE_FRAME.width + ':' + INLINE_FRAME.height + ':force_original_aspect_ratio=decrease,' +
591      'pad=' + INLINE_FRAME.width + ':' + INLINE_FRAME.height + ":(ow-iw)/2:(oh-ih)/2,fps=" + INLINE_FRAME.fps + "'",
592    '-pix_fmt rgb24 -c:v rawvideo -f image2 -update 1 -atomic_writing 1 -y ' + INLINE_FRAME.file,
593    '-ss ' + seek + ' -map 1:a:0' + (muted ? ' -af volume=0' : '') + ' -f audiotoolbox -'
594  ].join(' ');
595}
596
597async function startInline($, videoId, offsetSeconds, attempt = 0) {
598  stopInline();
599  const script = buildInlineScript(videoId, offsetSeconds, isMuted, toolPaths);
600  const proc = $.process.spawn({ argv: ['/bin/bash', '-c', script] });
601
602  let generation = 0;
603  const frameTimer = $.clock.every(Math.round(1000 / INLINE_FRAME.fps), () => {
604    generation += 1;
605    $.ui
606      .blit({ requestId: PANE_ID, key: INLINE_IMAGE_KEY, source: inlineFrameSource(generation) })
607      .catch(() => {});
608  });
609
610  const session = { videoId, offset: offsetSeconds, startedAt: Date.now(), proc, frameTimer };
611  inlineSession = session;
612  drainInBackground(proc, (code, stderr) => {
613    // Ended by itself (the video finished or the download failed), not by stopInline
614    if (inlineSession !== session) return;
615    stopInline();
616    // YouTube now and then refuses a download (HTTP 403); a fresh start usually works
617    if (code !== 0 && attempt < 2 && Date.now() - session.startedAt < 20000) {
618      startInline($, videoId, offsetSeconds, attempt + 1).catch(() => {});
619      return;
620    }
621    inlineOffset = 0;
622    isPlaying = false;
623    if (code !== 0) {
624      const line = stderr.split('\n').find((l) => /error/i.test(l)) || stderr.trim().split('\n')[0] || 'exit ' + code;
625      $.ui.toast('Inline playback stopped: ' + line.slice(0, 160));
626    }
627    $.ui.invalidate('ui.render');
628  });
629}
630
631// Routes a player command to the inline pane player or the native window.
632async function runPlayerCommand($, args) {
633  if (playbackMode !== 'inline') {
634    return runNativePipCommand($, args);
635  }
636  const [cmd, arg] = args;
637  try {
638    if (cmd === 'play') {
639      inlineOffset = 0;
640      await startInline($, arg, 0);
641    } else if (cmd === 'pause' || (cmd === 'toggle' && inlineSession)) {
642      stopInline();
643    } else if (cmd === 'resume' || cmd === 'toggle') {
644      await startInline($, currentVideo.id, inlineOffset);
645    } else if (cmd === 'stop') {
646      stopInline();
647      inlineOffset = 0;
648    } else if ((cmd === 'mute' || cmd === 'unmute') && inlineSession) {
649      // ffmpeg takes its volume at start, so restart from where it is
650      await startInline($, currentVideo.id, inlinePosition());
651    }
652    // position/size move the native window; the pane has no screen position
653    return true;
654  } catch (err) {
655    stopInline();
656    isPlaying = false;
657    $.ui.toast('Inline playback failed: ' + (err && err.message ? err.message : String(err)));
658    return false;
659  }
660}
661
662async function syncFromNativeStateFile($) {
663  try {
664    const raw = await $.fs.read(STATE_FILE_PATH);
665    if (!raw) return;
666    const parsed = JSON.parse(raw);
667    if (parsed && typeof parsed === 'object') {
668      if (parsed.position) {
669        screenPosition = parsed.position;
670      }
671      if (typeof parsed.customX === 'number' && typeof parsed.customY === 'number') {
672        customCoords = Math.round(parsed.customX) + ',' + Math.round(parsed.customY);
673      }
674      if (typeof parsed.isPaused === 'boolean') {
675        isPlaying = !parsed.shouldQuit && !parsed.isPaused;
676      }
677    }
678  } catch {
679    // Ignore missing state file before first launch
680  }
681}
682
683async function playYouTubeTarget($, rawTarget, requestedPos, requestedSize) {
684  // This terminal can play inline but the tools are missing: fetch them first
685  if (terminalInfo.needsDeps && modePreference !== 'window') {
686    await setupInlineDeps($);
687  }
688  if (requestedPos) {
689    const norm = normalizePosition(requestedPos);
690    screenPosition = norm;
691    if (norm === 'custom') {
692      customCoords = String(requestedPos).trim();
693    }
694  }
695  if (requestedSize) {
696    sizePreset = String(requestedSize).trim().toLowerCase();
697  }
698
699  const trimmed = String(rawTarget || '').trim();
700  let videoId = extractYouTubeId(trimmed);
701  let resolvedMeta = null;
702
703  if (!videoId && trimmed) {
704    const found = await searchYouTubeVideos($, trimmed);
705    searchResults = found;
706    if (found.length > 0) {
707      videoId = found[0].id;
708      resolvedMeta = found[0];
709    }
710  }
711
712  if (!videoId) {
713    videoId = currentVideo.id || CURATED_VIDEOS[0].id;
714  }
715
716  const meta = await resolveVideoMetadata($, videoId, resolvedMeta ? resolvedMeta.title : '');
717  currentVideo = meta;
718  isPlaying = true;
719  hasActiveVideo = true;
720
721  if (!playlist.some((item) => item.id === meta.id)) {
722    playlist = [meta, ...playlist.slice(0, 14)];
723  }
724
725  const posArg = screenPosition === 'custom' && customCoords ? customCoords : screenPosition;
726  await runPlayerCommand($, [
727    'play',
728    meta.id,
729    '--position',
730    posArg,
731    '--size',
732    sizePreset,
733    '--title',
734    meta.title,
735    '--opacity',
736    String(opacity)
737  ]);
738
739  await persistSettings($);
740  $.ui.invalidate('ui.render');
741  return meta;
742}
743
744async function movePlayerPosition($, newPos, newSize) {
745  const raw = String(newPos || '').trim();
746  if (raw === 'pane' || raw === 'band' || raw === 'both' || raw === 'spinner') {
747    uiPlacement = raw;
748    await persistSettings($);
749    $.ui.invalidate('ui.render');
750    return { kind: 'ui', placement: uiPlacement };
751  }
752
753  const norm = normalizePosition(raw);
754  screenPosition = norm;
755  if (norm === 'custom') {
756    customCoords = raw;
757  }
758  if (newSize) {
759    sizePreset = String(newSize).trim().toLowerCase();
760  } else if (norm === 'right-side' || norm === 'left-side') {
761    sizePreset = 'sidebar';
762  } else if (sizePreset === 'sidebar') {
763    sizePreset = 'medium';
764  }
765
766  const posArg = screenPosition === 'custom' && customCoords ? customCoords : screenPosition;
767  await runPlayerCommand($, ['position', posArg, sizePreset]);
768  await persistSettings($);
769  $.ui.invalidate('ui.render');
770  return { kind: 'screen', position: screenPosition, sizePreset };
771}
772
773const YT_HELP = [
774  '/yt                       open the player pane',
775  '/yt <url|id|search>       play (same as /yt play)',
776  '/yt play <url|search> [@position] [size]',
777  '/yt pause | resume        pause toggles when already paused',
778  '/yt stop                  stop playback and close the pane and bar',
779  '/yt hide | show           hide or show the UI; playback keeps going',
780  '/yt pos <where> [size]    move the popout window',
781  '/yt mode auto|inline|window',
782  '/yt width <cols>          pane width (the inline video scales with it)',
783  '/yt setup                 install yt-dlp + ffmpeg for inline video',
784  '/yt status'
785].join('\n');
786
787// Opened to watch, the pane leaves the keyboard with the prompt so typing goes
788// on; opened to use (/yt, /yt show, /yt pos), it takes the keys.
789async function openPlayerPane($, { focus = true } = {}) {
790  await $.ui.open({
791    id: PANE_ID,
792    title: 'YouTube Side Player',
793    ...(focus ? { focus: true } : {}),
794    closeOnEscape: true,
795    columns: paneColumns
796  });
797}
798
799async function stopPlayback($) {
800  isPlaying = false;
801  hasActiveVideo = false;
802  await runPlayerCommand($, ['stop']);
803  await $.ui.close({ id: PANE_ID });
804  $.ui.invalidate('ui.render');
805}
806
807function describeMode() {
808  const where = playbackMode === 'inline' ? 'inline in the pane' : 'in the popout window';
809  return (
810    'Playback: ' + where + ' (mode ' + modePreference + '; ' + terminalInfo.terminal + ': ' + terminalInfo.reason + ')'
811  );
812}
813
814export function register(on) {
815  on('session.start', async ($, e, next) => {
816    await loadSavedSettings($);
817
818    try {
819      await $.command.register({
820        name: 'yt',
821        description: 'YouTube side player: play, pause, resume, stop, hide, show, pos, mode, status',
822        argumentHint: '[play <url|search> | pause | resume | stop | hide | show | pos <where> | mode auto|inline|window | width <cols> | setup | status]',
823        immediate: true
824      });
825    } catch {
826      // Ignore duplicate command registration on reload
827    }
828
829    pluginRootPath = $.plugin.root;
830    depsDir = ((await $.env.get('HOME')) || '/tmp') + '/.claude/plugins/data/youtube-side-player/bin';
831    await detectPlaybackMode($, e.surface ?? (await $.session.surfaces())[0] ?? null);
832
833    // YouTube changes break old yt-dlp releases: refresh our own copy weekly
834    const checkedAt = await $.store.get('depsCheckedAt');
835    if (terminalInfo.canInline && (typeof checkedAt !== 'number' || Date.now() - checkedAt > DEPS_UPDATE_EVERY_MS)) {
836      void installDeps($, { update: true });
837    }
838
839    try {
840      await $.tool.register({
841        name: 'youtube_player',
842        description:
843          'Play a YouTube video on the side, search YouTube, or move the floating YouTube player to any screen or terminal position the user likes.',
844        inputSchema: {
845          type: 'object',
846          properties: {
847            action: {
848              type: 'string',
849              description: 'play, position, pause, resume, stop, or status'
850            },
851            queryOrUrl: {
852              type: 'string',
853              description: 'YouTube video URL, 11-character video ID, or search query'
854            },
855            position: {
856              type: 'string',
857              description:
858                'top-right, top-left, bottom-right, bottom-left, right-side, left-side, top-center, bottom-center, center, pane, band, both, or custom x,y,w,h'
859            },
860            size: {
861              type: 'string',
862              description: 'small, medium, large, or sidebar'
863            }
864          },
865          required: ['action']
866        }
867      });
868    } catch {
869      // Ignore duplicate tool registration on reload
870    }
871
872    return next(e);
873  });
874
875  on('classic.SessionStart', { source: 'clear' }, async ($, e, next) => {
876    await loadSavedSettings($);
877    return next(e);
878  });
879
880  on('command.run', { command: 'yt' }, async ($, e) => {
881    const rawArgs = String(e.args || '').trim();
882    if (playbackMode === 'window') {
883      await syncFromNativeStateFile($);
884    }
885    const [first = '', ...rest] = rawArgs.split(/\s+/).filter(Boolean);
886    const sub = first.toLowerCase();
887    const restText = rest.join(' ');
888
889    if (!sub || sub === 'show') {
890      isUiHidden = false;
891      // Bare /yt is to use the pane; /yt show only brings it back into view
892      await openPlayerPane($, { focus: !sub });
893      $.ui.invalidate('ui.render');
894      return {};
895    }
896    if (sub === 'help') {
897      return { text: YT_HELP };
898    }
899    if (sub === 'setup') {
900      const result = await setupInlineDeps($);
901      return { text: (result.ok ? result.message : 'Install failed: ' + result.message) + '\n' + describeMode() };
902    }
903    if (sub === 'pause' || sub === 'resume') {
904      const wantPlaying = sub === 'resume' || !isPlaying;
905      if (wantPlaying === isPlaying) {
906        return { text: (isPlaying ? 'Already playing: ' : 'Already paused: ') + currentVideo.title };
907      }
908      isPlaying = wantPlaying;
909      await runPlayerCommand($, [wantPlaying ? 'resume' : 'pause']);
910      $.ui.invalidate('ui.render');
911      return { text: (isPlaying ? 'Resumed: ' : 'Paused: ') + currentVideo.title };
912    }
913    if (sub === 'stop') {
914      await stopPlayback($);
915      return { text: 'Stopped YouTube Side Player.' };
916    }
917    if (sub === 'hide') {
918      isUiHidden = true;
919      await $.ui.close({ id: PANE_ID });
920      $.ui.invalidate('ui.render');
921      return { text: 'YouTube player UI hidden' + (isPlaying ? ' (still playing; /yt show to bring it back).' : '.') };
922    }
923    if (sub === 'pos' || sub === 'position') {
924      if (!restText) {
925        activeTab = 'position';
926        await openPlayerPane($);
927        return {};
928      }
929      const [posToken, sizeToken] = rest;
930      const updated = await movePlayerPosition($, posToken, sizeToken);
931      if (updated.kind === 'ui') {
932        return { text: 'YouTube Mod UI placement set to: ' + updated.placement };
933      }
934      return {
935        text:
936          'Moved YouTube Side Player to ' +
937          updated.position +
938          (screenPosition === 'custom' && customCoords ? ' (' + customCoords + ')' : '') +
939          ' [' +
940          updated.sizePreset +
941          ']' +
942          (playbackMode === 'inline' ? ' (applies to the popout window; inline video lives in the pane)' : '')
943      };
944    }
945    if (sub === 'width') {
946      const cols = Number(rest[0]);
947      if (!Number.isInteger(cols) || cols < 30 || cols > 160) {
948        return { text: 'Usage: /yt width <30-160>  (pane width in columns; now ' + paneColumns + ')' };
949      }
950      paneColumns = cols;
951      await persistSettings($);
952      // An open pane keeps its width: reopen it at the new one
953      if (!isUiHidden) {
954        await $.ui.close({ id: PANE_ID });
955        await openPlayerPane($, { focus: false });
956      }
957      $.ui.invalidate('ui.render');
958      return { text: 'Pane width set to ' + cols + ' columns.' };
959    }
960    if (sub === 'mode') {
961      const want = (rest[0] || '').toLowerCase();
962      if (want === 'auto' || want === 'inline' || want === 'window') {
963        const wasPlaying = isPlaying;
964        if (wasPlaying) await runPlayerCommand($, ['stop']);
965        modePreference = want;
966        await $.store.set('modePreference', modePreference);
967        applyModePreference();
968        if (wasPlaying) await playYouTubeTarget($, currentVideo.id, null, null);
969        $.ui.invalidate('ui.render');
970      } else if (want) {
971        return { text: 'Usage: /yt mode auto|inline|window' };
972      }
973      return { text: describeMode() };
974    }
975    if (sub === 'status') {
976      return {
977        text:
978          (isPlaying ? '▶ ' : '⏸ ') + currentVideo.title + '\n' + describeMode()
979      };
980    }
981
982    // `/yt play <target>` or the shorthand `/yt <url | id | search>`
983    const parts = sub === 'play' ? rest.slice() : rawArgs.split(/\s+/);
984    let sizeOverride = null;
985    let posOverride = null;
986
987    const knownSizes = new Set(['small', 'medium', 'large', 'sidebar']);
988    const knownPositions = new Set([
989      'top-left', 'top-center', 'top-right',
990      'left-side', 'center', 'right-side',
991      'bottom-left', 'bottom-center', 'bottom-right',
992      'tl', 'tc', 'tr', 'bl', 'bc', 'br', 'left', 'right', 'top', 'bottom',
993      'dock-left', 'dock-right'
994    ]);
995
996    if (parts.length >= 2 && knownSizes.has(parts[parts.length - 1].toLowerCase())) {
997      sizeOverride = parts.pop().toLowerCase();
998    }
999    if (parts.length >= 2) {
1000      const last = parts[parts.length - 1];
1001      if (last.startsWith('@')) {
1002        posOverride = parts.pop().slice(1);
1003      } else if (knownPositions.has(last.toLowerCase()) || /^\d+,\d+/.test(last)) {
1004        posOverride = parts.pop();
1005      }
1006    }
1007
1008    isUiHidden = false;
1009    // Open first so the inline Image is mounted when frames start arriving
1010    await openPlayerPane($, { focus: false });
1011    const meta = await playYouTubeTarget($, parts.join(' '), posOverride, sizeOverride);
1012    $.ui.toast(
1013      isPlaying
1014        ? 'Playing "' + meta.title + '" ' + (playbackMode === 'inline' ? 'in the pane' : 'at ' + screenPosition)
1015        : 'Could not play "' + meta.title + '"'
1016    );
1017    return {};
1018  });
1019
1020  on('tool.call', { tool: 'mcp__youtube-side-player__youtube_player' }, async ($, e) => {
1021    const action = String(e.action || 'status').toLowerCase();
1022    if (action === 'play') {
1023      const meta = await playYouTubeTarget($, e.queryOrUrl || '', e.position, e.size);
1024      return {
1025        result:
1026          'Playing "' +
1027          meta.title +
1028          '" (' +
1029          meta.id +
1030          ') at position ' +
1031          screenPosition +
1032          ' [' +
1033          sizePreset +
1034          '].'
1035      };
1036    }
1037    if (action === 'position' || action === 'move') {
1038      const updated = await movePlayerPosition($, e.position || 'top-right', e.size);
1039      return {
1040        result:
1041          updated.kind === 'ui'
1042            ? 'Updated Claude UI placement to ' + updated.placement
1043            : 'Moved YouTube Side Player to ' + updated.position + ' [' + updated.sizePreset + ']'
1044      };
1045    }
1046    if (action === 'pause') {
1047      isPlaying = false;
1048      await runPlayerCommand($, ['pause']);
1049      $.ui.invalidate('ui.render');
1050      return { result: 'Paused YouTube Side Player.' };
1051    }
1052    if (action === 'resume') {
1053      isPlaying = true;
1054      await runPlayerCommand($, ['resume']);
1055      $.ui.invalidate('ui.render');
1056      return { result: 'Resumed YouTube Side Player.' };
1057    }
1058    if (action === 'stop') {
1059      await stopPlayback($);
1060      return { result: 'Stopped and closed YouTube Side Player.' };
1061    }
1062    return {
1063      result: JSON.stringify({
1064        video: currentVideo,
1065        isPlaying,
1066        screenPosition,
1067        sizePreset,
1068        uiPlacement
1069      })
1070    };
1071  });
1072
1073  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
1074    if (!isPlaying) {
1075      return next(e);
1076    }
1077    const shortTitle =
1078      currentVideo.title.length > 28
1079        ? currentVideo.title.slice(0, 25) + '…'
1080        : currentVideo.title;
1081    return next({
1082      ...e,
1083      props: {
1084        ...e.props,
1085        suffix: ' · ▶ [' + screenPosition + '] ' + shortTitle
1086      }
1087    });
1088  });
1089
1090  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1091    if (uiPlacement !== 'band' && uiPlacement !== 'both') {
1092      return next(e);
1093    }
1094    if (isUiHidden || !hasActiveVideo) {
1095      return next(e);
1096    }
1097
1098    const { Box, Text, Button } = $.ui.resolve(e);
1099    const theirs = await next(e);
1100
1101    const bandBar = Box({
1102      borderStyle: 'round',
1103      borderColor: isPlaying ? 'red' : 'cyan',
1104      paddingX: 1,
1105      flexDirection: 'row',
1106      columnGap: 2,
1107      children: [
1108        Text({
1109          bold: true,
1110          color: isPlaying ? 'red' : 'cyan',
1111          children: [(isPlaying ? '▶ YT' : '⏸ YT') + ' [' + screenPosition + ' · ' + sizePreset + ']']
1112        }),
1113        Text({
1114          wrap: 'truncate-end',
1115          children: [currentVideo.title]
1116        }),
1117        Button({
1118          key: 'band-toggle-play',
1119          label: isPlaying ? 'Pause' : 'Play',
1120          plain: true,
1121          onPress: async () => {
1122            if (isPlaying) {
1123              isPlaying = false;
1124              await runPlayerCommand($, ['pause']);
1125            } else {
1126              await playYouTubeTarget($, currentVideo.id, screenPosition, sizePreset);
1127            }
1128            $.ui.invalidate('ui.render');
1129          }
1130        }),
1131        Button({
1132          key: 'band-cycle-pos',
1133          label: 'Move ↻',
1134          plain: true,
1135          onPress: async () => {
1136            const order = ['top-right', 'bottom-right', 'bottom-left', 'top-left', 'right-side', 'left-side'];
1137            const idx = order.indexOf(screenPosition);
1138            const nextPos = order[(idx + 1) % order.length];
1139            await movePlayerPosition($, nextPos, null);
1140          }
1141        }),
1142        Button({
1143          key: 'band-open-pane',
1144          label: 'Pane',
1145          plain: true,
1146          onPress: async () => {
1147            await $.ui.open({
1148              id: PANE_ID,
1149              title: 'YouTube Side Player',
1150              focus: true,
1151              closeOnEscape: true,
1152              columns: paneColumns
1153            });
1154          }
1155        })
1156      ]
1157    });
1158
1159    return Box({
1160      flexDirection: 'column',
1161      children: [theirs, bandBar]
1162    });
1163  });
1164
1165  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
1166    if (e.requestId !== PANE_ID) {
1167      return next(e);
1168    }
1169
1170    const { Box, Text, Button, Input, Select, Link, Markdown, Raster, Svg, Image } = $.ui.resolve(e);
1171    const redraw = () => $.ui.invalidate('ui.render');
1172
1173    const tabButton = (id, label, hotkey) =>
1174      Button({
1175        key: 'tab-' + id,
1176        label,
1177        hotkey,
1178        plain: true,
1179        dimColor: activeTab !== id,
1180        onPress: () => {
1181          activeTab = id;
1182          redraw();
1183        }
1184      });
1185
1186    const headerTabs = Box({
1187      flexDirection: 'row',
1188      columnGap: 3,
1189      children: [
1190        tabButton('player', 'Player', '1'),
1191        tabButton('position', 'Position (' + screenPosition + ')', '2'),
1192        tabButton('playlist', 'Playlist (' + playlist.length + ')', '3'),
1193        tabButton('notes', 'Notes & AI (' + videoNotes.length + ')', '4')
1194      ]
1195    });
1196
1197    const visualMonitor =
1198      e.surface === 'terminal' && Raster
1199        ? Raster({
1200            key: 'screen-radar',