Play YouTube Shorts in your Claude Code


/shorts Play YouTube Shorts in your Claude Code 💃
Early. cc-shorts works on Claude Code 2.1.287, the one version tested so far. It runs on hooks modules, an early-access plugin API that changes between releases, so another version may fail to load it.
audiotoolbox output device. Homebrew's ffmpeg has it.The first /shorts checks for these tools and offers to have Claude install the missing ones (see First run). To install them yourself with Homebrew:
brew install yt-dlp ffmpeg # Homebrew's yt-dlp brings deno along
From Claude Code:
/plugin marketplace add mthli/cc-shorts
/plugin install cc-shorts@cc-shorts
Then start a new session. Claude Code loads the plugin once you trust the folder the session runs in.
Or run it from a clone:
git clone https://github.com/mthli/cc-shorts.git
claude --plugin-dir /path/to/cc-shorts
/shorts asks whether Claude should install them (with brew install …) instead of opening the pane. Claude's commands go through your permission settings like any others. The install prompt tells Claude to leave Homebrew's "taps are not trusted" warning alone (no brew trust, no brew untap), and to stop and tell you when something needs sudo or your password./shorts asks which browser you are signed in to YouTube with, offering the ones you have used on this Mac. Run /shorts browser to change it later.security tool, which yt-dlp runs, read the browser's "Safe Storage" key from the Keychain. Allow grants one read, so the prompt returns for each feed request, like and watch-history report. Always Allow ends the prompts, and from then on any program that runs security can read that key without asking.While the pane has the keyboard, it switches macOS to an English layout so an input method cannot swallow these keys:
| Key | Does |
|---|---|
j | Next Short |
k | Previous Short, from the start |
p | Pause / play |
r | Replay from the start |
l | Like / unlike, on your account |
m | Mute / unmute |
o | Pause and open the Short in your browser |
x | Close the pane |
The author's name under the video opens the Short too. Closing the pane keeps your place for the rest of the session, so the next /shorts resumes the same Short.
l likes and unlikes.$TMPDIR/cc-shorts/), and deletes them when the pane closes or the session ends.The pane says what failed:
/shorts browser.j and answer the Keychain prompt with Allow. A browser you have never used on this Mac has no cookies; switch with /shorts browser.j.PATH, and a tool outside it counts as missing.YouTube changes its site often, and yt-dlp keeps up in new releases: brew upgrade yt-dlp (or pipx upgrade yt-dlp) fixes most breakage. cc-shorts relies on some yt-dlp internals too, so a new yt-dlp can break cc-shorts until cc-shorts ships an update.
claude plugin test . # tests
claude plugin validate .claude-plugin/plugin.json # the plugin and its hooks module
claude plugin validate . # the marketplace: with marketplace.json here, `.` checks only that
bunx -p typescript tsc -p . --noEmit # types; .claude-plugin/types/ appears once Claude Code has loaded the plugin
bunx prettier@3 --write hooks tests types # format TypeScript (.prettierrc.json)
uvx ruff format helper # format Python (ruff.toml)
claude --plugin-dir . reloads the plugin on every save. docs/research.md holds the product decisions, the research behind them and the verification log; .claude/maps/ explains how the code works.
MIT License
Copyright (c) 2026 Matthew Leehooks/register.tsx 876 lines1import { atom, read, update } from 'claude-code'
2import type {
3 EngineInterface,
4 HookStream,
5 ImageSource,
6 ProcessSpawnChunk,
7 ProcessSpawnResult,
8 Register,
9 Timer,
10} from 'claude-code'
11
12import type { Frame, Mode, Short, Shorts } from '../types'
13import {
14 BROWSERS,
15 blankCells,
16 browserOptions,
17 browserQuestion,
18 clockTime,
19 ffmpegArgs,
20 findBrowser,
21 frameSize,
22 installPrompt,
23 installQuestion,
24 lastLine,
25 nameList,
26 parseProgress,
27 setupToast,
28 shebangPython,
29 toCells,
30 videoBox,
31} from './lib'
32import type { Box, Missing } from './lib'
33
34const PANE = 'shorts'
35const VIDEO = 'video'
36/** The widest key the pane draws (`r: Replay`, `o: Open ↗`), and the space between keys. */
37const KEY_COLUMNS = 9
38const KEY_GAP = 2
39/** The answer to the setup question that hands the install to Claude. */
40const INSTALL = 'Ask Claude to install'
41/** A Short counts as watched (and goes to the account's history) after this. */
42const WATCHED_SECONDS = 10
43/** How many watched ids `$.store` keeps, so the feed skips them. */
44const SEEN_KEPT = 1000
45/** Videos kept downloaded ahead of the one playing. */
46const PRELOAD = 5
47/** Refill the queue when this few are left after the one playing. */
48const LOW_WATER = 5
49
50const EMPTY: Shorts = {
51 queue: [],
52 cur: 0,
53 shorts: {},
54 status: 'idle',
55 message: '',
56 pos: 0,
57 muted: false,
58 isLoggedIn: true,
59}
60const shorts = atom({ plugin: 'cc-shorts', key: 'shorts' } as const, EMPTY)
61
62type Player = {
63 id: string
64 stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>
65 mode: Mode
66 frame: Frame
67 /** Where this ffmpeg started, and where it is now, in seconds. */
68 start: number
69 pos: number
70 stderr: string
71}
72
73// What lives only as long as this load of the module: a hot reload kills the
74// ffmpeg and cancels the timers with it, and `session.start` picks up again
75// from `shorts`, which the host keeps.
76let dir = ''
77/** The box the pane last drew the picture in, from the render hook. */
78let layout: Box | undefined
79/** What the picture shows now, so a redraw draws the same. */
80let lastSource: ImageSource | undefined
81let lastCells: string | undefined
82/** What `shorts` held when a /clear began, for the session after it. */
83let carried: Shorts | undefined
84/** The Python that runs `helper/`, one that has yt_dlp; '' until the setup check finds it. */
85let python = ''
86/** The last setup check, which names `python`: a helper waits for it. */
87let checking: Promise<Missing[]> | undefined
88/** True once a check found everything `/shorts` needs; until then each `/shorts` checks again. */
89let isSetUp = false
90/**
91 * yt-dlp's name for the browser whose cookies the helpers read, as the person
92 * chose it (kept in `$.store`); '' until they do, and `yt.py` reads Chrome's.
93 */
94let browser = ''
95/** Bumped by every action that changes what plays; stale work checks it. */
96let epoch = 0
97let player: Player | undefined
98let ticker: Timer | undefined
99/** Downloads failed in a row: past a few, the network is down, not the video. */
100let failures = 0
101/**
102 * Frame files are counted per load, so the load's start goes in their names
103 * too: a reload counts from 1 again, and ffmpeg will not write over a file
104 * the last load left behind.
105 */
106const loadMark = Date.now().toString(36)
107let frameSeq = 0
108let isBlitting = false
109let generation = 0
110let lastDeny = ''
111const marked = new Set<string>()
112let refilling: Promise<string | undefined> | undefined
113/** Downloads not started yet, in the order they start (`download`). */
114const waiting: { id: string; start: () => Promise<void>; drop: () => void }[] = []
115let isDownloading = false
116const downloads = new Map<string, Promise<Short | undefined>>()
117/** Whether the pane held the keyboard at its last draw: each change acts once. */
118let hadKeys = false
119let keysChain: Promise<unknown> = Promise.resolve()
120let likeChain: Promise<unknown> = Promise.resolve()
121
122const log = ($: EngineInterface, text: string) => $.ui.log(text, { to: 'debug' })
123const setShorts = ($: EngineInterface, change: (s: Shorts) => Shorts) => update($, shorts, change)
124/** `setShorts` for the play begun at `my`: after a newer action, even a retried write leaves the state alone. */
125const setShortsAt = ($: EngineInterface, my: number, change: (s: Shorts) => Shorts) =>
126 setShorts($, s => (my === epoch ? change(s) : s))
127
128type HelperReply<T> = { value: T; error?: undefined } | { value?: undefined; error: string }
129
130/** Runs a script of `helper/` and parses the JSON it prints, or says why it could not; never rejects. */
131async function runHelper<T>(
132 $: EngineInterface,
133 script: 'yt.py' | 'ime.py',
134 args: string[],
135 stdin: string | undefined,
136 timeoutMs: number,
137): Promise<HelperReply<T>> {
138 let error: string
139 try {
140 await checking
141 const env = browser === '' ? undefined : { CC_SHORTS_BROWSER: browser }
142 const argv = [python, '-B', `${$.plugin.root}/helper/${script}`, ...args]
143 const run = await $.process.run(argv, { stdin, timeoutMs, env })
144 if (run.exitCode === 0) return { value: JSON.parse(lastLine(run.stdout)) as T }
145 error = lastLine(run.stderr) || `exit ${run.exitCode}`
146 } catch (err) {
147 error = String(err)
148 }
149 log($, `${script} ${args[0]} failed: ${error}`)
150 return { error }
151}
152
153/** What `runHelper` parsed; undefined on failure. */
154async function helper<T>(
155 $: EngineInterface,
156 script: 'yt.py' | 'ime.py',
157 args: string[],
158 stdin: string | undefined,
159 timeoutMs: number,
160): Promise<T | undefined> {
161 return (await runHelper<T>($, script, args, stdin, timeoutMs)).value
162}
163
164// --- the hooks ---------------------------------------------------------------
165
166export const register: Register = on => {
167 on('session.start', async ($, e, next) => {
168 const started = await next(e)
169 const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/$/, '')
170 // A /clear keeps the folder of the session before it, and says so in
171 // `shorts` for a reload after it to find.
172 dir = (await read($, shorts)).dir ?? `${tmp}/cc-shorts/${await $.session.id()}`
173 const chosen = await $.store.get('browser').catch(() => undefined)
174 browser = typeof chosen === 'string' ? chosen : ''
175 // Unawaited: the first prompt waits for this hook, and a helper waits for the check.
176 const check = checkSetup($)
177 void check.then(missing => {
178 const toast = setupToast(missing)
179 if (toast !== undefined) $.ui.toast(toast, { timeoutMs: 10_000 })
180 })
181 await $.command.register({
182 name: 'shorts',
183 description: 'Play YouTube Shorts in your Claude Code',
184 argumentHint: '[browser]',
185 })
186 // Downloads of sessions that ended without cleaning up (a crash).
187 // prettier-ignore
188 void $.process.run([
189 'find', `${tmp}/cc-shorts`, '-mindepth', '1', '-maxdepth', '1', '-type', 'd', '-mtime', '+1',
190 '-exec', 'rm', '-rf', '{}', '+',
191 ])
192 // After a hot reload: the pane may still be up, its ffmpeg gone.
193 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
194 const s = await read($, shorts)
195 // A source still kept means the last load switched it and never gave it back.
196 // No draw comes to a closed pane: it goes back once the check has named
197 // the helper's Python.
198 hadKeys = s.inputSource !== undefined
199 if (!isOpen) void check.then(() => holdKeys($, false))
200 if (isOpen && (s.status === 'playing' || s.status === 'loading')) void play($, s.pos)
201 else if (isOpen && s.status === 'paused' && s.mode === 'raster' && s.frame) {
202 // The cells on screen went with the old module; the paused frame's
203 // file is still there to draw them again.
204 void frameCells($, s.frame).then(
205 cells => {
206 lastCells = cells
207 $.ui.invalidate('ui.render')
208 },
209 () => undefined,
210 )
211 } else if (!isOpen && s.status !== 'idle') await shutDown($)
212 return started
213 })
214
215 on('command.run', { command: 'shorts' }, async ($, e) => {
216 // Checked again until it passes: the person may have installed something since.
217 if (!isSetUp) {
218 const missing = await checkSetup($)
219 if (missing.some(m => !m.isOptional)) {
220 void offerInstall($, missing)
221 return {}
222 }
223 }
224 // The first time, and on `/shorts browser`: whose cookies the feed comes through.
225 if ((browser === '' || e.args.trim() === 'browser') && !(await chooseBrowser($))) return {}
226 await $.ui.open({ id: PANE, title: 'Shorts', focus: true, columns: 50, rows: 40 })
227 const s = await read($, shorts)
228 if (s.status === 'idle' || s.status === 'error') void play($, s.pos)
229 return {}
230 })
231
232 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
233 if (e.surface !== 'terminal') {
234 const { Text } = $.ui.resolve(e)
235 return <Text dimColor>Run Claude Code in a terminal to play Shorts.</Text>
236 }
237 const { Box, Text, Button, Image, Raster } = $.ui.resolve(e)
238 const s = await read($, shorts)
239 // The draw is the first to see the keyboard come or go (the person's
240 // ctrl+x tab, a click, Esc); the switch runs on after it returns. Idle is
241 // a pane closing (`shutDown`), whose last draws still hold the keyboard.
242 void holdKeys($, e.props.isFocused && s.status !== 'idle')
243 layout = videoBox(e.props.bodyColumns, e.props.scroll.bodyRows)
244 const short = s.shorts[s.queue[s.cur] ?? '']
245 const f = s.frame
246 const hasPicture = f !== undefined && (s.status === 'playing' || s.status === 'paused')
247
248 let picture
249 if (hasPicture && s.mode === 'raster') {
250 picture = (
251 <Raster key={VIDEO} columns={f.columns} rows={f.rows} cells={lastCells ?? blankCells(f.columns, f.rows)} />
252 )
253 } else if (hasPicture) {
254 const source = lastSource ?? { file: f.file, format: 'rgb' as const, width: f.width, height: f.height }
255 picture = <Image key={VIDEO} source={source} columns={f.columns} rows={f.rows} alt={short?.title || ' '} />
256 } else {
257 picture = (
258 <Box width={layout.columns} height={layout.rows} alignItems="center" justifyContent="center">
259 <Text dimColor wrap="wrap">
260 {s.message || 'Run /shorts to start'}
261 </Text>
262 </Box>
263 )
264 }
265
266 const isLiked = short !== undefined && hasLike(s, short.id)
267 const where = `${clockTime(s.pos)} / ${clockTime(short?.duration ?? 0)}`
268 const state = s.status === 'paused' ? `⏸ ${where}` : s.status === 'playing' ? `▶ ${where}` : ''
269 const notes = [
270 state,
271 isLiked ? 'Liked' : '',
272 s.muted ? 'Muted' : '',
273 s.isLoggedIn ? '' : 'Signed out: /shorts browser to switch',
274 ]
275 .filter(Boolean)
276 .join(' · ')
277 // Each key in a cell as wide as the widest, so the two rows' columns line
278 // up and a label that changes (Pause to Play) moves nothing.
279 const cell = (key: string, hotkey: string, label: string, onPress: () => void) => (
280 <Box width={KEY_COLUMNS}>
281 <Button key={key} plain hotkey={hotkey} label={label} onPress={onPress} />
282 </Box>
283 )
284
285 return (
286 <Box flexDirection="column" alignItems="center">
287 {picture}
288 {short ? (
289 <Box height={1} overflow="hidden">
290 <Button
291 key="author"
292 plain
293 label={`${short.author || 'YouTube'} ↗`}
294 onPress={() => void openInBrowser($, short.id)}
295 />
296 </Box>
297 ) : (
298 <Text> </Text>
299 )}
300 {/* Two rows whatever the title's length, so the buttons stay put. */}
301 <Box height={2} overflow="hidden">
302 <Text wrap="wrap">{short?.title ?? ' '}</Text>
303 </Box>
304 <Text dimColor wrap="truncate">
305 {notes || ' '}
306 </Text>
307 {/* Two rows of four: eight in one row outgrow the 50 columns asked for. */}
308 <Box flexDirection="row" columnGap={KEY_GAP} flexWrap="wrap">
309 {cell('next', 'j', 'Next', () => void skip($, 1))}
310 {cell('previous', 'k', 'Prev', () => void skip($, -1))}
311 {cell('pause', 'p', s.status === 'paused' ? 'Play' : 'Pause', () => void togglePause($))}
312 {cell('replay', 'r', 'Replay', () => void skip($, 0))}
313 </Box>
314 <Box flexDirection="row" columnGap={KEY_GAP} flexWrap="wrap">
315 {cell('like', 'l', isLiked ? 'Unlike' : 'Like', () => void toggleLike($))}
316 {cell('mute', 'm', s.muted ? 'Unmute' : 'Mute', () => void toggleMute($))}
317 {cell('open', 'o', 'Open ↗', () => void (short && openInBrowser($, short.id)))}
318 {cell('close', 'x', 'Close', () => void shutDown($).then(() => $.ui.close({ id: PANE })))}
319 </Box>
320 </Box>
321 )
322 })
323
324 // The person's close (ctrl+x x, the pane's mark); `x` shuts down itself,
325 // since a close the plugin raises does not come back to its own hook.
326 on('ui.close', { id: PANE }, async ($, e, next) => {
327 await shutDown($)
328 return next(e)
329 })
330
331 on('session.end', async ($, e, next) => {
332 if (e.reason === 'clear') {
333 // A /clear ends the conversation, not the process: the pane, the ffmpeg
334 // and the timers carry on, but the host empties `$.state` and fires no
335 // `session.start`. Keep what plays for `classic.SessionStart` to put back.
336 carried = await read($, shorts)
337 return next(e)
338 }
339 epoch++
340 stopPlayer()
341 await holdKeys($, false)
342 if (dir !== '') await $.process.run(['rm', '-rf', dir], { timeoutMs: 2000 }).catch(() => undefined)
343 return next(e)
344 })
345
346 // Fires once the session a /clear starts is in place, `$.state` already empty.
347 on('classic.SessionStart', async ($, e, next) => {
348 const kept = carried
349 carried = undefined
350 if (e.source === 'clear' && kept !== undefined) await setShorts($, () => ({ ...kept, dir }))
351 return next(e)
352 })
353}
354
355// --- setup -------------------------------------------------------------------
356
357/**
358 * Looks for what `/shorts` needs, naming the helpers' Python on the way.
359 * Each tool runs from Claude Code's own PATH, as playback runs it: one
360 * installed out of that PATH counts as missing.
361 */
362function checkSetup($: EngineInterface): Promise<Missing[]> {
363 checking = (async () => {
364 const home = (await $.env.get('HOME')) ?? ''
365 const [found, ffmpeg, hasDeno] = await Promise.all([
366 findPython($, home),
367 $.process.run(['ffmpeg', '-hide_banner', '-devices'], { timeoutMs: 10_000 }).catch(() => undefined),
368 succeeds($, ['deno', '--version']),
369 ])
370 python = found
371 const missing: Missing[] = []
372 if (found === '') missing.push({ name: 'yt-dlp', why: 'no Python that imports yt_dlp', formula: 'yt-dlp' })
373 if (ffmpeg?.exitCode !== 0) missing.push({ name: 'ffmpeg', why: 'not found', formula: 'ffmpeg' })
374 else if (!/\baudiotoolbox\b/.test(ffmpeg.stdout)) {
375 missing.push({ name: 'ffmpeg', why: 'this ffmpeg has no audiotoolbox output for the sound', formula: 'ffmpeg' })
376 }
377 if (!hasDeno) {
378 const why = "not found; yt-dlp solves YouTube's JS challenges with it and may miss formats without it"
379 missing.push({ name: 'deno', why, formula: 'deno', isOptional: true })
380 }
381 isSetUp = !missing.some(m => !m.isOptional)
382 return missing
383 })()
384 return checking
385}
386
387/**
388 * A Python that imports yt_dlp: the one the `yt-dlp` on the PATH runs on, by
389 * its `#!` line (pipx's, Homebrew's, pip's), else pipx's own wherever its
390 * home is (`~/.local/pipx`, or a newer pipx's `~/Library/Application
391 * Support/pipx`); '' when none does.
392 */
393async function findPython($: EngineInterface, home: string): Promise<string> {
394 const pythons: string[] = []
395 const which = await $.process.run(['which', 'yt-dlp']).catch(() => undefined)
396 const script = which?.exitCode === 0 ? which.stdout.trim() : ''
397 if (script !== '') {
398 const head = await $.process.run(['head', '-n', '1', script]).catch(() => undefined)
399 const named = shebangPython(head?.stdout ?? '')
400 if (named !== undefined) pythons.push(named)
401 }
402 const pipxHomes = [await $.env.get('PIPX_HOME'), `${home}/.local/pipx`, `${home}/Library/Application Support/pipx`]
403 for (const root of pipxHomes) if (root) pythons.push(`${root}/venvs/yt-dlp/bin/python`)
404 for (const candidate of pythons) if (await succeeds($, [candidate, '-c', 'import yt_dlp'])) return candidate
405 return ''
406}
407
408/** Whether `argv` starts and exits 0 within 10 s. */
409const succeeds = ($: EngineInterface, argv: string[]) =>
410 $.process.run(argv, { timeoutMs: 10_000 }).then(
411 run => run.exitCode === 0,
412 () => false,
413 )
414
415/** Asks to install what is missing, and hands the install to Claude on a yes. */
416async function offerInstall($: EngineInterface, missing: Missing[]) {
417 const hasBrew = await succeeds($, ['which', 'brew'])
418 // Dismissed, or no one to ask (`-p`): nothing to do.
419 const options = { header: 'cc-shorts', options: [INSTALL, 'Not now'] }
420 const answer = await $.ui.ask(installQuestion(missing, hasBrew), options).catch(() => '')
421 // The person chose it: Claude reads it as their own words.
422 if (answer === INSTALL) await $.prompt.submit({ text: installPrompt(missing, hasBrew), asUser: true })
423}
424
425/**
426 * Asks which browser the person is signed in to YouTube with, offering those
427 * used here, and keeps the answer; false when they named none yt-dlp reads.
428 */
429async function chooseBrowser($: EngineInterface): Promise<boolean> {
430 const root = `${(await $.env.get('HOME')) ?? ''}/Library/Application Support`
431 const isHere = await Promise.all(
432 BROWSERS.map(b => b.dir === undefined || $.fs.exists(`${root}/${b.dir}`).catch(() => false)),
433 )
434 const here = BROWSERS.filter((_, i) => isHere[i])
435 // Safari alone (every Mac has it) leaves nothing to ask.
436 const question = { header: 'Browser', options: browserOptions(here, browser) }
437 const answer =
438 here.length < 2 ? (here[0]?.name ?? '') : await $.ui.ask(browserQuestion(here, browser), question).catch(() => '')
439 if (answer === '') return false
440 const chosen = findBrowser(answer)
441 if (chosen === undefined) {
442 $.ui.toast(`cc-shorts: yt-dlp cannot read ${answer}; it reads ${nameList(BROWSERS.map(b => b.name))}`, {
443 timeoutMs: 10_000,
444 })
445 return false
446 }
447 // Where the feed had scrolled to belongs to the account it scrolled with.
448 if (chosen.id !== (browser || 'chrome')) await $.store.delete('token')
449 browser = chosen.id
450 await $.store.set('browser', browser)
451 return true
452}
453
454// --- playback ----------------------------------------------------------------
455
456/** Plays the Short at `cur` from `from` seconds, downloading it if need be. */
457async function play($: EngineInterface, from = 0) {
458 const my = ++epoch
459 stopPlayer()
460 let s = await read($, shorts)
461 if (s.queue.length <= s.cur) {
462 await setShortsAt($, my, s => ({ ...s, status: 'loading', message: 'Fetching the feed…' }))
463 const error = await refill($)
464 if (my !== epoch) return
465 s = await read($, shorts)
466 if (s.queue.length <= s.cur) {
467 const why = error === undefined ? '' : ` (${error})`
468 const message = `Could not get the feed${why}. Press j to retry, or switch browsers with /shorts browser`
469 await setShortsAt($, my, s => ({ ...s, status: 'error', message }))
470 return
471 }
472 }
473 const id = s.queue[s.cur] ?? ''
474 if (s.shorts[id]?.path === undefined) {
475 await setShortsAt($, my, s => ({ ...s, status: 'loading', message: 'Downloading…' }))
476 }
477 // Skipped on from already: what plays now goes first, not this one.
478 if (my !== epoch) return
479 // Waiting since before a skip, and no longer next: those never start.
480 const near = nextFew(s)
481 dropWaiting(id => near.includes(id))
482 const short = await download($, id, true)
483 if (my !== epoch) return
484 if (short?.path === undefined) {
485 if (++failures >= 3) {
486 failures = 0
487 await setShortsAt($, my, s => ({
488 ...s,
489 status: 'error',
490 message: 'Downloads keep failing. Check the network, then press j to retry',
491 }))
492 return
493 }
494 $.ui.toast('cc-shorts: download failed, skipping to the next one')
495 await setShortsAt($, my, s => ({ ...s, cur: s.cur + 1, pos: 0 }))
496 if (my !== epoch) return
497 return play($)
498 }
499 failures = 0
500 void prepare($)
501 await start($, short, from, my)
502}
503
504async function start($: EngineInterface, short: Short, from: number, my: number) {
505 // The first draw of the pane says how big the picture can be.
506 for (let i = 0; i < 40 && layout === undefined; i++) await $.clock.sleep(50)
507 if (my !== epoch) return
508 const s = await read($, shorts)
509 const mode = s.mode ?? 'image'
510 const box = layout ?? videoBox(50, 40)
511 await $.process.run(['mkdir', '-p', dir])
512 // Skipped on while this waited: an ffmpeg spawned now would have nothing
513 // left to stop it, so the newer play spawns its own.
514 if (my !== epoch) return
515 const name = `frame-${loadMark}-${++frameSeq}.rgb`
516 const frame: Frame = { file: `${dir}/${name}`, ...frameSize(mode, box), ...box }
517 const argv = ffmpegArgs({ path: short.path ?? '', start: from, mode, frame, isMuted: s.muted || !short.hasAudio })
518 const p: Player = { id: short.id, stream: $.process.spawn({ argv }), mode, frame, start: from, pos: from, stderr: '' }
519 player = p
520 // Every older frame file: the one shown while paused, and any an ffmpeg
521 // still dying wrote after it was stopped.
522 void $.process.run(['find', dir, '-name', 'frame-*', '!', '-name', `${name}*`, '-delete'])
523 lastSource = undefined
524 lastCells = undefined
525 // Stopped while this writes: a retried write would draw over the newer one,
526 // and the ticker would outlive the stop that already ran.
527 await setShortsAt($, my, s => ({ ...s, status: 'playing', message: '', pos: from, frame }))
528 if (my !== epoch) return
529 void markSeen($, short.id)
530 ticker = $.clock.every(33, () => void tick($))
531 void follow($, p, short)
532}
533
534/** Puts ffmpeg's newest frame on screen; one at a time, about 30 a second. */
535async function tick($: EngineInterface) {
536 const p = player
537 if (p === undefined || isBlitting) return
538 isBlitting = true
539 try {
540 if (layout !== undefined && (layout.columns !== p.frame.columns || layout.rows !== p.frame.rows)) {
541 // The pane changed size: start again in the new box, where it was.
542 void play($, p.pos)
543 return
544 }
545 let result
546 if (p.mode === 'image') {
547 const { file, width, height } = p.frame
548 const source: ImageSource = { file, format: 'rgb', width, height, generation: ++generation }
549 result = await $.ui.blit({ requestId: PANE, key: VIDEO, source })
550 if (!result.deny) lastSource = source
551 } else {
552 const cells = await frameCells($, p.frame)
553 result = await $.ui.blit({ requestId: PANE, key: VIDEO, cells })
554 if (!result.deny) lastCells = cells
555 }
556 if (result.deny && player === p) await denied($, p, result.deny)
557 } catch {
558 // The first frame is not written yet.
559 } finally {
560 isBlitting = false
561 }
562}
563
564/** The frame file as Raster cells; rejects while it is not written. */
565async function frameCells($: EngineInterface, frame: Frame): Promise<string> {
566 const { base64 } = await $.fs.read(frame.file, { as: 'bytes' })
567 return toCells(Uint8Array.fromBase64(base64), frame.columns, frame.rows)
568}
569
570async function denied($: EngineInterface, p: Player, reason: string) {
571 if (p.mode === 'image' && /\balt\b/.test(reason)) {
572 // This terminal draws no pictures (iTerm2): cells from here on.
573 log($, `no pictures here, falling back to cells: ${reason}`)
574 await setShorts($, s => ({ ...s, mode: 'raster' }))
575 void play($, p.pos)
576 return
577 }
578 // Not drawn yet, mostly: the next tick tries again.
579 if (reason !== lastDeny) log($, `blit refused: ${reason}`)
580 lastDeny = reason
581}
582
583/** Reads one ffmpeg's progress until it ends, then moves on. */
584async function follow($: EngineInterface, p: Player, short: Short) {
585 let rest = ''
586 let isEnded = false
587 let shown = Math.floor(p.pos)
588 try {
589 for await (const chunk of p.stream) {
590 if (chunk.stream === 'stderr') {
591 p.stderr = (p.stderr + chunk.text).slice(-2000)
592 continue
593 }
594 const progress = parseProgress(rest + chunk.text)
595 rest = progress.rest
596 isEnded ||= progress.isEnded
597 if (progress.time === undefined || player !== p) continue
598 p.pos = p.start + progress.time
599 if (Math.floor(p.pos) !== shown) {
600 shown = Math.floor(p.pos)
601 await setShorts($, s => ({ ...s, pos: p.pos }))
602 }
603 if (!marked.has(p.id) && p.pos >= Math.min(WATCHED_SECONDS, short.duration / 2)) {
604 marked.add(p.id)
605 void helper($, 'yt.py', ['watched', p.id], undefined, 60_000)
606 }
607 }
608 } catch (err) {
609 p.stderr += String(err)
610 }
611 // Stopped on purpose: what stopped it carries on, and a stopped stream's
612 // result may never settle.
613 if (player !== p) return
614 const ended = await p.stream.result.catch(() => undefined)
615 if (player !== p) return
616 stopPlayer()
617 if (isEnded && ended?.code === 0) {
618 await setShorts($, s => ({ ...s, cur: s.cur + 1, pos: 0 }))
619 void play($)
620 return
621 }
622 log($, `ffmpeg ended ${JSON.stringify(ended)}: ${p.stderr}`)
623 const why = lastLine(p.stderr)
624 await setShorts($, s => ({
625 ...s,
626 status: 'error',
627 pos: p.pos,
628 message: `Playback failed${why === '' ? '' : ` (${why})`}. Press j for the next Short`,
629 }))
630}
631
632function stopPlayer() {
633 const p = player
634 player = undefined
635 ticker?.cancel()
636 ticker = undefined
637 // Ending the stream ends the ffmpeg.
638 if (p) void p.stream.return({ code: null, signal: null }).catch(() => undefined)
639}
640
641// --- the feed ----------------------------------------------------------------
642
643type FeedReply = { ids: string[]; token: string | null; source: string; loggedIn: boolean }
644
645/** Pulls the next batch of the feed onto the queue, one call at a time; resolves why it could not. */
646function refill($: EngineInterface): Promise<string | undefined> {
647 refilling ??= (async () => {
648 const s = await read($, shorts)
649 const token = await $.store.get('token')
650 const seen = ((await $.store.get('seen')) as string[] | undefined) ?? []
651 const request = { token: typeof token === 'string' ? token : null, seen: [...seen, ...s.queue], want: 10 }
652 const { value: reply, error } = await runHelper<FeedReply>($, 'yt.py', ['feed'], JSON.stringify(request), 120_000)
653 if (reply === undefined) return error
654 log($, `feed: ${reply.ids.length} from ${reply.source}`)
655 if (reply.token === null) await $.store.delete('token')
656 else await $.store.set('token', reply.token)
657 await setShorts($, s => ({
658 ...s,
659 queue: [...s.queue, ...reply.ids.filter(id => !s.queue.includes(id))],
660 isLoggedIn: reply.loggedIn,
661 }))
662 })().finally(() => {
663 refilling = undefined
664 })
665 return refilling
666}
667
668async function markSeen($: EngineInterface, id: string) {
669 const seen = ((await $.store.get('seen')) as string[] | undefined) ?? []
670 if (!seen.includes(id)) await $.store.set('seen', [...seen, id].slice(-SEEN_KEPT))
671}
672
673// --- downloads ---------------------------------------------------------------
674
675/**
676 * The Short downloaded, once. Downloads run one after another in the order
677 * asked, except that an `isUrgent` one (the Short to play now) goes ahead of
678 * every one still waiting; the one already running finishes first.
679 */
680function download($: EngineInterface, id: string, isUrgent = false): Promise<Short | undefined> {
681 const at = waiting.findIndex(job => job.id === id)
682 if (isUrgent && at > 0) waiting.unshift(...waiting.splice(at, 1))
683 const known = downloads.get(id)
684 if (known) return known
685 const job = new Promise<Short | undefined>(resolve => {
686 const entry = { id, start: () => fetchShort($, id).then(resolve), drop: () => resolve(undefined) }
687 if (isUrgent) waiting.unshift(entry)
688 else waiting.push(entry)
689 })
690 downloads.set(id, job)
691 // A failure may pass (a network blip): let a later ask try again.
692 void job.then(short => {
693 if (short === undefined && downloads.get(id) === job) downloads.delete(id)
694 })
695 void downloadNext()
696 return job
697}
698
699/** Starts the downloads waiting, one at a time, until none is left. */
700async function downloadNext() {
701 if (isDownloading) return
702 isDownloading = true
703 for (let job = waiting.shift(); job !== undefined; job = waiting.shift()) await job.start()
704 isDownloading = false
705}
706
707async function fetchShort($: EngineInterface, id: string): Promise<Short | undefined> {
708 try {
709 const had = (await read($, shorts)).shorts[id]
710 if (had?.path !== undefined && (await $.fs.exists(had.path))) return had
711 const short = await helper<Short>($, 'yt.py', ['download', id, dir], undefined, 180_000)
712 if (short === undefined) return undefined
713 await setShorts($, s => ({ ...s, shorts: { ...s.shorts, [id]: short } }))
714 return short
715 } catch (err) {
716 log($, `download ${id}: ${String(err)}`)
717 return undefined
718 }
719}
720
721/** Keeps the next few downloaded, the queue long enough, and old files gone. */
722async function prepare($: EngineInterface) {
723 const s = await read($, shorts)
724 if (s.queue.length - s.cur - 1 <= LOW_WATER) void refill($)
725 for (const id of nextFew(s).slice(1)) void download($, id)
726 // One behind stays for `k`; the rest are deleted.
727 const old = s.queue.slice(0, Math.max(0, s.cur - 1)).filter(id => s.shorts[id]?.path !== undefined)
728 if (old.length === 0) return
729 await $.process.run(['rm', '-f', ...old.map(id => s.shorts[id]?.path ?? '')])
730 for (const id of old) downloads.delete(id)
731 await setShorts($, next => {
732 const kept = { ...next.shorts }
733 for (const id of old) if (kept[id]) kept[id] = { ...kept[id], path: undefined }
734 return { ...next, shorts: kept }
735 })
736}
737
738/** The Short at `cur` and the PRELOAD after it. */
739const nextFew = (s: Shorts) => s.queue.slice(s.cur, s.cur + 1 + PRELOAD)
740
741/** Takes each download waiting whose Short `keep` turns down out of line: it never starts. */
742function dropWaiting(keep: (id: string) => boolean) {
743 const dropped = waiting.filter(job => !keep(job.id))
744 waiting.splice(0, waiting.length, ...waiting.filter(job => keep(job.id)))
745 for (const job of dropped) job.drop()
746}
747
748// --- the input source --------------------------------------------------------
749
750/**
751 * Keeps an input method off the hotkeys: a Chinese or Japanese one takes the
752 * letters before the terminal sees them. While the pane holds the keyboard
753 * the input source is an English layout, and the one it had comes back once
754 * the pane lets go; one switch at a time, in order.
755 */
756function holdKeys($: EngineInterface, isHeld: boolean): Promise<unknown> {
757 // Drawn before the setup check named the helper's Python (a hot reload):
758 // a later draw acts on it.
759 if (isHeld !== hadKeys && python !== '') {
760 hadKeys = isHeld
761 keysChain = keysChain
762 .then(() => (isHeld ? toEnglish($) : giveBackInput($)))
763 .catch(err => log($, `input source: ${String(err)}`))
764 }
765 return keysChain
766}
767
768async function toEnglish($: EngineInterface) {
769 const reply = await helper<{ was: string | null }>($, 'ime.py', ['english'], undefined, 5000)
770 const was = reply?.was
771 // Nothing switched (English already): a source kept from before stays.
772 if (typeof was === 'string') await setShorts($, s => ({ ...s, inputSource: was }))
773}
774
775async function giveBackInput($: EngineInterface) {
776 const { inputSource } = await read($, shorts)
777 if (inputSource === undefined) return
778 await helper($, 'ime.py', ['select', inputSource], undefined, 5000)
779 await setShorts($, s => ({ ...s, inputSource: undefined }))
780}
781
782// --- what the keys do --------------------------------------------------------
783
784/** Plays the Short `by` along the queue from the start; 0 plays this one again. */
785async function skip($: EngineInterface, by: number) {
786 await setShorts($, s => ({ ...s, cur: Math.min(s.queue.length, Math.max(0, s.cur + by)), pos: 0 }))
787 void play($)
788}
789
790async function togglePause($: EngineInterface) {
791 const s = await read($, shorts)
792 if (s.status === 'paused') return void play($, s.pos)
793 await pause($)
794}
795
796async function pause($: EngineInterface) {
797 const p = player
798 if (p !== undefined) {
799 epoch++
800 stopPlayer()
801 await setShorts($, s => ({ ...s, status: 'paused', pos: p.pos }))
802 return
803 }
804 // Nothing on screen yet, but one on its way (the feed, its download, ffmpeg
805 // starting): a newer epoch keeps it from starting, and the pane drops the
806 // frame of the Short before.
807 const { status } = await read($, shorts)
808 if (status !== 'loading' && status !== 'playing') return
809 // Started while this read: paused as any other.
810 if (player !== undefined) return pause($)
811 epoch++
812 await setShorts($, s => ({ ...s, status: 'paused', message: 'Paused', frame: undefined }))
813}
814
815/**
816 * Likes the Short on screen, or takes the like back: the pane shows it at
817 * once, and YouTube hears of it one call at a time (each about 6 s).
818 */
819async function toggleLike($: EngineInterface) {
820 // Flipped inside the write: two presses landing together flip it twice.
821 const s = await setShorts($, s => {
822 const id = s.queue[s.cur]
823 return id === undefined ? s : { ...s, liked: withLike(s.liked, id, !hasLike(s, id)) }
824 })
825 const id = s.queue[s.cur]
826 if (id === undefined) return
827 const isLiked = hasLike(s, id)
828 likeChain = likeChain
829 .then(async () => {
830 // Pressed again since: that press sends its own.
831 if (hasLike(await read($, shorts), id) !== isLiked) return
832 if ((await helper($, 'yt.py', [isLiked ? 'like' : 'unlike', id], undefined, 60_000)) !== undefined) return
833 await setShorts($, s => (hasLike(s, id) === isLiked ? { ...s, liked: withLike(s.liked, id, !isLiked) } : s))
834 $.ui.toast(`cc-shorts: ${isLiked ? 'like' : 'unlike'} failed`)
835 })
836 .catch(err => log($, `like ${id}: ${String(err)}`))
837}
838
839const hasLike = (s: Shorts, id: string) => (s.liked ?? []).includes(id)
840
841function withLike(liked: string[] | undefined, id: string, isLiked: boolean): string[] {
842 const others = (liked ?? []).filter(i => i !== id)
843 return isLiked ? [...others, id] : others
844}
845
846async function toggleMute($: EngineInterface) {
847 const s = await setShorts($, s => ({ ...s, muted: !s.muted }))
848 if (player !== undefined) void play($, player.pos)
849 $.ui.toast(s.muted ? 'cc-shorts: muted' : 'cc-shorts: unmuted')
850}
851
852/** Pauses here first, so the browser's copy is not heard over this one. */
853async function openInBrowser($: EngineInterface, id: string) {
854 await pause($)
855 await $.process.run(['open', `https://www.youtube.com/shorts/${id}`])
856}
857
858/** Stops everything and deletes this session's downloads and frames. */
859async function shutDown($: EngineInterface) {
860 epoch++
861 stopPlayer()
862 // What has not started never does: the folder it would land in goes below.
863 dropWaiting(() => false)
864 downloads.clear()
865 layout = undefined
866 await setShorts($, s => ({
867 ...s,
868 status: 'idle',
869 frame: undefined,
870 shorts: Object.fromEntries(Object.entries(s.shorts).map(([id, short]) => [id, { ...short, path: undefined }])),
871 }))
872 // No draw sees the keyboard go: an idle pane holds none, a closed one draws no more.
873 await holdKeys($, false)
874 if (dir !== '') await $.process.run(['rm', '-rf', dir])
875}
876hooks/lib.ts 254 lines1// The pure parts of the player: the words of the setup check and the browser
2// question, sizes, the ffmpeg command, its progress output, and frames as
3// Raster cells. No `$` here, so tests reach all of it.
4
5import type { Frame, Mode } from '../types'
6
7/** The JSON a helper command printed: its last line. */
8export function lastLine(text: string): string {
9 return text.trim().split('\n').pop() ?? ''
10}
11
12// --- setup -------------------------------------------------------------------
13
14/** Something `/shorts` needs that the setup check could not find, or use. */
15export type Missing = {
16 /** What the person knows it by. */
17 name: string
18 /** Why it counts as missing, for Claude to start from. */
19 why: string
20 /** The Homebrew formula that brings it. */
21 formula: string
22 /** Shorts play without it, only worse. */
23 isOptional?: boolean
24}
25
26/** The interpreter a script's `#!` line names, `env` looked through; undefined when it names none. */
27export function shebangPython(firstLine: string): string | undefined {
28 const [exe, ...rest] = firstLine.startsWith('#!') ? firstLine.slice(2).trim().split(/\s+/) : []
29 return (exe?.endsWith('/env') ? rest.find(word => !word.startsWith('-')) : exe) || undefined
30}
31
32/** `['a', 'b', 'c']` as `a, b and c`. */
33export function nameList(names: string[]): string {
34 return names.length < 2 ? (names[0] ?? '') : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`
35}
36
37/** The `brew install` that brings everything in `missing`. */
38export function brewCommand(missing: Missing[]): string {
39 return `brew install ${[...new Set(missing.map(m => m.formula))].join(' ')}`
40}
41
42const isNeeded = (missing: Missing[]) => missing.filter(m => !m.isOptional)
43
44/** What a session starts with while something is missing; undefined when nothing is. */
45export function setupToast(missing: Missing[]): string | undefined {
46 if (missing.length === 0) return undefined
47 const lacks = `cc-shorts: ${nameList(missing.map(m => m.name))} ${missing.length > 1 ? 'are' : 'is'} missing`
48 // Only deno is optional: yt-dlp solves YouTube's JS challenges with it.
49 if (isNeeded(missing).length === 0) return `${lacks}, so yt-dlp may miss formats; run ${brewCommand(missing)}`
50 return `${lacks}; /shorts offers to install ${missing.length > 1 ? 'them' : 'it'}`
51}
52
53/** What `/shorts` asks while something it needs is missing. */
54export function installQuestion(missing: Missing[], hasBrew: boolean): string {
55 const optional = missing.filter(m => m.isOptional).map(m => m.name)
56 const helps = optional.length > 0 ? `, plus ${nameList(optional)}, which helps yt-dlp find formats` : ''
57 const lacks = `cc-shorts is missing ${nameList(isNeeded(missing).map(m => m.name))}${helps}.`
58 if (!hasBrew) return `${lacks} Homebrew, which installs them, is missing too. Ask Claude to walk you through it?`
59 return `${lacks} Install with \`${brewCommand(missing)}\`?`
60}
61
62/** What Claude is asked once the person lets it install what is missing. */
63export function installPrompt(missing: Missing[], hasBrew: boolean): string {
64 const command = brewCommand(missing)
65 return [
66 'Install what the cc-shorts plugin is missing on this Mac:',
67 ...missing.map(m => `- ${m.name}: ${m.why}`),
68 '',
69 hasBrew
70 ? `Run \`${command}\`. It can take several minutes: give it a long timeout.`
71 : 'Homebrew is missing too. Do not install it yourself: its installer asks for my password, so tell me to ' +
72 `run the one from https://brew.sh in my own terminal, then run \`${command}\` once it is in.`,
73 "cc-shorts runs these from Claude Code's PATH: if I have one already, find out why cc-shorts cannot see it.",
74 // Homebrew 7 warns about every untrusted tap on an install, with the
75 // `brew trust` and `brew untap` lines that would silence it.
76 'Each formula is in homebrew/core, which needs no tap trust. Brew may warn that other taps are not trusted; ' +
77 'that warning does not block this install, so leave it and run no `brew trust` or `brew untap`.',
78 'If brew needs sudo, a password or anything else from me, stop and tell me what it said.',
79 'Once it is done, tell me to run /shorts again.',
80 ].join('\n')
81}
82
83// --- the browser -------------------------------------------------------------
84
85/**
86 * A browser yt-dlp reads cookies from on macOS: yt-dlp's name for it, the
87 * person's, and its folder under `~/Library/Application Support`, there once
88 * it has been used here.
89 */
90export type Browser = { id: string; name: string; dir?: string }
91
92/** Every browser yt-dlp reads, the likeliest first. Safari has no folder to look for: every Mac has it. */
93export const BROWSERS: readonly Browser[] = [
94 { id: 'chrome', name: 'Chrome', dir: 'Google/Chrome' },
95 { id: 'safari', name: 'Safari' },
96 { id: 'edge', name: 'Edge', dir: 'Microsoft Edge' },
97 { id: 'firefox', name: 'Firefox', dir: 'Firefox/Profiles' },
98 { id: 'brave', name: 'Brave', dir: 'BraveSoftware/Brave-Browser' },
99 { id: 'opera', name: 'Opera', dir: 'com.operasoftware.Opera' },
100 { id: 'vivaldi', name: 'Vivaldi', dir: 'Vivaldi' },
101 { id: 'chromium', name: 'Chromium', dir: 'Chromium' },
102 { id: 'whale', name: 'Whale', dir: 'Naver/Whale' },
103]
104
105/** The browser an answer names, by either name, or within it (`Google Chrome`); undefined for one yt-dlp cannot read. */
106export function findBrowser(answer: string): Browser | undefined {
107 const said = answer.trim().toLowerCase()
108 if (said === '') return undefined
109 return (
110 BROWSERS.find(b => said === b.id || said === b.name.toLowerCase()) ??
111 BROWSERS.find(b => said.split(/\s+/).includes(b.id))
112 )
113}
114
115/** The browser question's choices: the browsers here, the one in use first, four at most (the dialog's limit). */
116export function browserOptions(here: readonly Browser[], current: string): string[] {
117 return [...here.filter(b => b.id === current), ...here.filter(b => b.id !== current)].slice(0, 4).map(b => b.name)
118}
119
120/**
121 * The browser question, naming the browsers here that do not fit in its
122 * choices, and what Safari's cookies cost: macOS lets only an app with Full
123 * Disk Access read them, and that app is the terminal, for all it runs.
124 */
125export function browserQuestion(here: readonly Browser[], current: string): string {
126 const offered = browserOptions(here, current)
127 const others = here.filter(b => !offered.includes(b.name)).map(b => b.name)
128 const [are, it] = others.length > 1 ? ['are', 'one'] : ['is', 'it']
129 const more = others.length > 0 ? ` ${nameList(others)} ${are} on this Mac too: type ${it} in.` : ''
130 const safari = offered.includes('Safari') ? ' Picking Safari means giving your terminal Full Disk Access.' : ''
131 return `Which browser are you signed in to YouTube with? cc-shorts reads your feed through its cookies.${more}${safari}`
132}
133
134// --- the pane and playback ---------------------------------------------------
135
136/** Rows under the picture: author, title (two), status line, buttons (two). */
137export const CHROME_ROWS = 6
138
139export type Box = { columns: number; rows: number }
140
141const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))
142const even = (n: number) => Math.max(2, Math.round(n / 2) * 2)
143
144/**
145 * The largest 9:16 box of cells that fits the pane's body above the chrome.
146 * A cell is about twice as tall as it is wide, so 9:16 is 9 columns for
147 * every 8 rows.
148 */
149export function videoBox(bodyColumns: number, bodyRows: number): Box {
150 const room = Math.max(2, bodyRows - CHROME_ROWS)
151 const columns = clamp(Math.min(bodyColumns, Math.floor((room * 9) / 8)), 2, 255)
152 return { columns, rows: clamp(Math.round((columns * 8) / 9), 1, room) }
153}
154
155/**
156 * The pixels of one frame: in `raster` one per column and two per row (the
157 * upper and lower half of `▀`); in `image` about ten per column, which the
158 * terminal scales to the box, at most 480 wide (the download's width).
159 */
160export function frameSize(mode: Mode, box: Box): { width: number; height: number } {
161 if (mode === 'raster') return { width: box.columns, height: box.rows * 2 }
162 const width = even(clamp(box.columns * 10, 96, 480))
163 return { width, height: even((width * 16) / 9) }
164}
165
166/**
167 * The ffmpeg that plays one Short from `start` seconds at its own pace: each
168 * frame rewrites `frame.file` whole (a rename, so a reader never sees half a
169 * frame), the sound goes straight to the speakers, and the progress comes as
170 * `key=value` lines on stdout.
171 *
172 * `raster` frames are cut to 32 colors each, so the cells use at most
173 * 32 x 32 = 1024 color pairs: what the Raster paints without falling back
174 * to nearest colors.
175 */
176export function ffmpegArgs(o: { path: string; start: number; mode: Mode; frame: Frame; isMuted: boolean }): string[] {
177 const { width: w, height: h } = o.frame
178 const fit = `scale=${w}:${h}:force_original_aspect_ratio=decrease:flags=${o.mode === 'raster' ? 'area' : 'bilinear'},pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2`
179 const palette =
180 'split[a][b];[a]palettegen=max_colors=32:stats_mode=single:reserve_transparent=0[p];[b][p]paletteuse=new=1:dither=none'
181 const filter = o.mode === 'raster' ? `${fit},${palette},format=rgb24` : `${fit},format=rgb24`
182 // prettier-ignore
183 return [
184 'ffmpeg', '-hide_banner', '-nostdin', '-loglevel', 'error',
185 '-progress', 'pipe:1', '-stats_period', '0.25',
186 '-ss', o.start.toFixed(2), '-re', '-i', o.path,
187 '-map', '0:v:0', '-vf', filter, '-c:v', 'rawvideo',
188 '-f', 'image2', '-update', '1', '-atomic_writing', '1', o.frame.file,
189 ...(o.isMuted ? [] : ['-map', '0:a:0', '-f', 'audiotoolbox', '-']),
190 ]
191}
192
193export type Progress = {
194 /** Seconds played since this ffmpeg started, when a line said so. */
195 time?: number
196 /** True once ffmpeg reported `progress=end`. */
197 isEnded: boolean
198 /** A line cut off at the end of the text: lead the next piece with it. */
199 rest: string
200}
201
202/** Reads ffmpeg's `-progress` lines out of one piece of its stdout. */
203export function parseProgress(text: string): Progress {
204 const lines = text.split('\n')
205 const rest = lines.pop() ?? ''
206 let time: number | undefined
207 let isEnded = false
208 for (const line of lines) {
209 const [key, value] = line.trim().split('=')
210 if (key === 'out_time_us' && value !== undefined && /^\d+$/.test(value)) time = Number(value) / 1e6
211 if (key === 'progress' && value === 'end') isEnded = true
212 }
213 return { time, isEnded, rest }
214}
215
216const UPPER_HALF = 0x2580
217const DEFAULT_COLOR = 0x01000000
218
219/**
220 * One rgb24 frame of `columns` x `rows * 2` pixels as Raster cells: each
221 * cell a `▀` whose foreground is the upper pixel and background the lower.
222 */
223export function toCells(rgb: Uint8Array, columns: number, rows: number): string {
224 const words = new Uint32Array(columns * rows * 3)
225 for (let y = 0; y < rows; y++) {
226 for (let x = 0; x < columns; x++) {
227 const top = (2 * y * columns + x) * 3
228 const bottom = top + columns * 3
229 const cell = (y * columns + x) * 3
230 words[cell] = UPPER_HALF
231 words[cell + 1] = ((rgb[top] ?? 0) << 16) | ((rgb[top + 1] ?? 0) << 8) | (rgb[top + 2] ?? 0)
232 words[cell + 2] = ((rgb[bottom] ?? 0) << 16) | ((rgb[bottom + 1] ?? 0) << 8) | (rgb[bottom + 2] ?? 0)
233 }
234 }
235 return new Uint8Array(words.buffer).toBase64()
236}
237
238/** Cells for a box with nothing to show yet: black. */
239export function blankCells(columns: number, rows: number): string {
240 const words = new Uint32Array(columns * rows * 3)
241 for (let cell = 0; cell < words.length; cell += 3) {
242 words[cell] = 0x20
243 words[cell + 1] = DEFAULT_COLOR
244 words[cell + 2] = 0
245 }
246 return new Uint8Array(words.buffer).toBase64()
247}
248
249/** `75` as `1:15`. */
250export function clockTime(seconds: number): string {
251 const s = Math.max(0, Math.floor(seconds))
252 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
253}
254types/index.d.ts 70 lines1/** One Short as its download describes it. */
2export type Short = {
3 id: string
4 title: string
5 author: string
6 /** Seconds. */
7 duration: number
8 /** False for the rare Short with no sound track: played muted. */
9 hasAudio: boolean
10 /** The downloaded mp4, absent until downloaded (or once cleaned up). */
11 path?: string
12}
13
14/**
15 * How a frame reaches the pane: `image`, real pixels (kitty, Ghostty);
16 * `raster`, half-block cells, two pixels a cell (iTerm2 and the rest).
17 */
18export type Mode = 'image' | 'raster'
19
20/** The frame file the running ffmpeg rewrites, and the box it is drawn in. */
21export type Frame = {
22 file: string
23 width: number
24 height: number
25 columns: number
26 rows: number
27}
28
29export type Status = 'idle' | 'loading' | 'playing' | 'paused' | 'error'
30
31/** The feed and the player, as the pane draws them. */
32export type Shorts = {
33 /** Video ids in feed order; `cur` is the one on screen. */
34 queue: string[]
35 cur: number
36 shorts: Record<string, Short>
37 status: Status
38 /** What the pane says while there is no picture, or what went wrong. */
39 message: string
40 /** Seconds into the current Short. */
41 pos: number
42 muted: boolean
43 /**
44 * Ids liked from the pane this session, as last pressed: YouTube may still
45 * be catching up. A Short not here counts as not liked.
46 */
47 liked?: string[]
48 /** Undecided until the first frame: `image` is tried first. */
49 mode?: Mode
50 frame?: Frame
51 /** False when the last feed call came back logged out. */
52 isLoggedIn: boolean
53 /**
54 * The input source (macOS) the pane switched away from when it took the
55 * keyboard, put back when it lets go; absent when it switched nothing.
56 */
57 inputSource?: string
58 /**
59 * The folder downloads and frames go in, once a /clear has passed: it keeps
60 * the id of the session before, and a reload must not take the new one.
61 */
62 dir?: string
63}
64
65declare module 'claude-code' {
66 interface PluginState {
67 'cc-shorts': { shorts: Shorts }
68 }
69}
70