SLOPSHOPPER

cc-shorts

Play YouTube Shorts in your Claude Code

newpanecommandtoastprocesstimer
★ 3v0.1.0MITupdated 2026-10-02mthli/cc-shorts
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-shorts
│ ┃ shorts ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Run /shorts to start │ cc-shorts │ │ ┃ ⏺ Read(src/auth.ts) │ cc-shorts: ffmpeg is missing; /shorts │ │ ┃ ⎿ Read 6 lines │ offers to install it │ │ ┃ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /shorts │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · shorts
Run /shorts to start j: Next k: Prev p: Pause r: Replay l: Like m: Mute o: Open ↗ x: Close
README

cc-shorts

/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.

Requirements

  • macOS. cc-shorts plays sound through ffmpeg's AudioToolbox device and switches the macOS input source.
  • Claude Code in a terminal. The desktop app and IDE extensions do not play the pane. Terminals with the kitty graphics protocol (Ghostty, kitty) show real pixels; the rest (iTerm2, tmux, …) get half-block characters.
  • yt-dlp, from Homebrew, pipx or pip.
  • ffmpeg with the audiotoolbox output device. Homebrew's ffmpeg has it.
  • deno, recommended: yt-dlp solves YouTube's JavaScript challenges with it and may miss formats without it.
  • A browser signed in to YouTube: Chrome, Safari, Edge, Firefox, Brave, Opera, Vivaldi, Chromium or Whale. cc-shorts reads your feed with that browser's cookies, and YouTube serves no Shorts feed to a signed-out visitor. yt-dlp cannot read Arc.

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

Install

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

First run

  1. The setup check. Each session checks for yt-dlp, ffmpeg and deno in the background and toasts what is missing. While yt-dlp or ffmpeg is missing, /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.
  2. The browser. The first /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.
  3. Access to the cookies.
  4. Chrome, Edge, Brave and the other Chromium browsers: macOS asks to let the 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.
  5. Firefox needs no grant.
  6. Safari keeps its cookies where only an app with Full Disk Access can read them (System Settings › Privacy & Security); Files & Folders does not reach them. You grant it to your terminal app, and everything the terminal runs gets it too. If that is too broad, pick another browser.

Keys

While the pane has the keyboard, it switches macOS to an English layout so an input method cannot swallow these keys:

KeyDoes
jNext Short
kPrevious Short, from the start
pPause / play
rReplay from the start
lLike / unlike, on your account
mMute / unmute
oPause and open the Short in your browser
xClose 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.

What it does with your account

  • It reads your feed through YouTube's internal web API (the Shorts sequence the website scrolls), with your browser's cookies. That API is unofficial and may break whenever YouTube changes it.
  • It writes to your account. cc-shorts adds a Short to your watch history once you have watched 10 seconds of it (half of one under 20 seconds), as the website does. l likes and unlikes.
  • It goes against YouTube's Terms of Service, as any automated client signed in as you does, and that puts the account at risk. cc-shorts keeps its requests near a person's scrolling pace: about 15 Shorts per batch, and the next batch once 5 are left.
  • Your cookies stay on your Mac. yt-dlp reads them locally and sends them only to YouTube. cc-shorts downloads Shorts at most 480 pixels wide, five ahead of the one playing, into a temporary folder ($TMPDIR/cc-shorts/), and deletes them when the pane closes or the session ends.

When something goes wrong

The pane says what failed:

  • "Could not get the feed (signed out: no YouTube login in …)": sign in to YouTube in that browser, or switch with /shorts browser.
  • "Could not get the feed (cannot read …'s cookies: …)": with Safari, give your terminal Full Disk Access. With Chrome or another Chromium browser, press j and answer the Keychain prompt with Allow. A browser you have never used on this Mac has no cookies; switch with /shorts browser.
  • "Downloads keep failing": check the network, then press j.
  • cc-shorts says a tool is missing, but you installed it: cc-shorts looks for tools on Claude Code's own 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.

Development

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.

License

MIT License

Copyright (c) 2026 Matthew Lee
Source 3 files
hooks/register.tsx 876 lines
1import { 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}
876
hooks/lib.ts 254 lines
1// 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}
254
types/index.d.ts 70 lines
1/** 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