在 Claude Code 侧边面板播放本地视频(ffmpeg 解帧,ffplay 出声)

在 Claude Code 侧边面板里播放本地视频或网络视频,agent 跑长任务的时候看点东西,不用切出终端。
| 要求 | 说明 |
|---|---|
| ffmpeg、ffprobe | 必需。macOS 上 brew install ffmpeg,会一起装上 ffplay |
| ffplay | 可选。没有时只放画面,状态行注明「无声音」 |
| yt-dlp | 可选,只有网页链接需要:brew install yt-dlp。直链不需要 |
| 能显示图片的终端 | kitty、Ghostty 等支持图形协议的终端。其他终端看不到画面,播放会停下并提示换终端 |
| 终端界面 | desktop、vscode 等界面打开面板只显示一行提示 |
目前只在 macOS + Ghostty 上验证过。
在 Claude Code 终端会话里安装(默认装在用户范围,所有项目都可用):
/plugin install video-player --marketplace androidZzT/cc-moyu-kit
或者从源码加载:
claude --plugin-dir /path/to/cc-moyu-kit/mods/video-player
| 命令 | 作用 |
|---|---|
/video <绝对路径> [fps] | 打开面板播放这个文件;fps 1–60,默认 15。正在播放时会先停掉旧的 |
/video <http(s) 链接> [fps] | 播放网络视频:ffprobe 能直接读的直链照原样播放,读不了的交给 yt-dlp 解析(单个视频,短边不超过 480,优先 H.264) |
/video pause | 暂停:画面停在当前帧,声音停止 |
/video resume | 从暂停的位置继续 |
/video seek <位置> | 跳转:05:30、330(秒)跳到绝对位置,+10、-10 相对跳;暂停中跳转会直接开始播放 |
/video volume <0–100> | 设视频音量,取消静音 |
/video mute | 切换静音 |
/video stop | 停止画面和声音,删除临时帧,关闭面板 |
路径里可以有空格,也可以用引号括起来:/video "/Users/me/My Movie.mkv" 24。
画面下方两行:
━━━━━━━━━━━━━━━━━━━━──────────────────────────────────────────
[⏸ 暂停] [« 10s] [10s »] [🔉] [🔊] [🔇] 01:05 / 14:48 · 15 fps · 音量 100%
p 暂停/继续,h 后退 10 秒,l 前进 10 秒,j/k 音量减/加 10%,m 静音。快捷键只在面板有焦点时生效,不影响输入框/video 一次即可不支持:直播、需要登录或 cookie 的内容、播放列表、字幕、倍速。
$TMPDIR/cc-moyu-video/play.XXXXXX/s<段号>/ 下:每次播放一个目录,每次继续或跳转开一个新段,新段出画面后删除旧段/video stop、换片时删除本次目录claude plugin validate .
claude plugin test .
npx -p typescript tsc -p . # 先用 --plugin-dir 加载一次,生成 .claude-plugin/types
| 文件 | 内容 |
|---|---|
hooks/register.tsx | 所有用到 $ 的逻辑:命令、分段与进程、计时、换帧、暂停与跳转、音量、渲染、state 与 store |
hooks/plan.ts | 参数解析,ffmpeg/ffprobe/ffplay 命令行(含 -ss、请求头),解析它们的输出(含 ffplay 的声音时钟),帧尺寸 |
hooks/timeline.ts | 帧号、是否播完、状态行、该删哪些帧、画面行数、跳转目标、进度条格子 |
hooks/cleanup.ts | 临时目录位置,遗留目录的查找与过滤 |
hooks/source.ts | 链接判断、yt-dlp 命令行与输出解析、请求头参数 |
hooks/volume.ts | 音量设定的校验、加减、静音、状态行标签 |
测试里的 tests/harness.ts 在 mod 下面模拟时钟、终端、which/ffprobe/rm 等命令,以及可由测试喂进度、结束或被 mod 结束的 ffmpeg/ffplay 子进程。
hooks/register.tsx 605 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Player } from '../types'
5import { frameRoot, parseStale, staleArgv } from './cleanup'
6import {
7 audioArgv,
8 decodeArgv,
9 firstAudioClock,
10 framePath,
11 frameSize,
12 lastProgressFrame,
13 parseArgs,
14 parseProbe,
15 parseWhich,
16 probeArgv,
17 whichArgv,
18} from './plan'
19import type { FrameSize, Probe, Tools } from './plan'
20import { isUrl, parseYtdlp, plainMedia, ytdlpArgv } from './source'
21import type { Media } from './source'
22import {
23 DONE_LINE,
24 dueFrame,
25 formatClock,
26 frameToShow,
27 framesToDelete,
28 isFinished,
29 positionAt,
30 progressCells,
31 rowsFor,
32 seekTarget,
33 statusLine,
34} from './timeline'
35import type { SeekTarget } from './timeline'
36import { DEFAULT_VOLUME, VOLUME_KEY, audibleLevel, readVolume, setLevel, stepVolume, toggleMute, volumeLabel } from './volume'
37import type { Volume } from './volume'
38
39// `$` may only be handed to functions declared in this file, so every engine-facing step lives here;
40// the other modules hold pure logic. A playback runs as segments: each is an ffmpeg writing raw RGB
41// frames from one start position at playback speed, an ffplay for the sound from the same position,
42// and timers that swap the keyed Image's `{ file }` source with `$.ui.blit`. Pause ends the segment;
43// resume and seek start a new one. A volume change restarts only the segment's ffplay. Each ffplay's
44// first sound clock re-anchors the picture clock, so a sound that starts late is not left behind.
45// A link is read directly when ffprobe can, else resolved by yt-dlp into addresses and headers.
46
47const PANE_ID = 'video-player'
48const PANE_TITLE = '视频'
49const IMAGE_KEY = 'view'
50const STATS_MS = 1_000
51/** While paused, the playback directory is touched this often so no session takes it for a leftover. */
52const TOUCH_MS = 60_000
53const STEP_SEC = 10
54const USAGE =
55 '用法:/video <绝对路径|http(s) 链接> [fps 1–60,默认 15] · /video pause | resume | seek <mm:ss|秒|+N|-N> | volume <0–100> | mute | stop'
56const NEED_FFMPEG = '需要 ffmpeg 和 ffprobe:brew install ffmpeg'
57const NEED_YTDLP = '播放网页链接需要 yt-dlp:brew install yt-dlp'
58const NO_LIVE = '不支持直播'
59const CANNOT_RESOLVE = '解析不了这个链接'
60const NEED_TERMINAL = '这个终端不能显示图片,请在 kitty、Ghostty 等支持图片的终端里播放'
61const TERMINAL_ONLY = '视频只能在终端里播放(kitty、Ghostty 等)'
62const NOT_PLAYING = '没有在播放'
63const DECODING_LINE = '解码中…'
64const PLAYED_CELL = '━'
65const UNPLAYED_CELL = '─'
66const ALT_DENY = /\balt\b/i
67const ERROR_DETAIL_CHARS = 200
68const IDLE: Player = { status: 'idle', width: 0, height: 0, line: USAGE }
69
70const playerAtom = atom({ plugin: 'video-player', key: 'player' } as const, IDLE)
71
72/**
73 * One playback and its current segment. Frame counters live here, off the state, so a frame swap
74 * never redraws the pane; `segment` tells a stale segment's loop and timers that they are done.
75 */
76type Run = Readonly<{
77 id: number
78 /** Where the picture is read from, and the sound: one address for a file, maybe two for a web page. */
79 video: Media
80 audio: Media
81 fps: number
82 dir: string
83 size: FrameSize
84 durationSec: number
85 hasSound: boolean
86 isPaused: boolean
87 pausedAtSec: number
88 segment: number
89 segDir: string
90 offsetSec: number
91 decoded: number
92 isDecodeDone: boolean
93 shown: number
94 deletedThrough: number
95 startedAt?: number
96 shownSinceStats: number
97 /** Frames shown in the last full second, for the status line. */
98 lastFps: number
99 /** Ends the segment's ffplay alone, so a volume change can restart the sound without the picture. */
100 stopSound?: () => void
101 /** Counts the segment's ffplay starts; only the latest one's clock re-anchors the picture. */
102 sound: number
103 /** The frame file on screen, kept across segments so the picture never blanks while a new one starts. */
104 displayPath?: string
105 /** The previous segment's directory, removed once the new segment's first frame is on screen. */
106 prevSegDir?: string
107 /** Why the last frame swap was refused, shown in the status line until a swap succeeds. */
108 lastDeny?: string
109 stops: readonly (() => void)[]
110}>
111
112let run: Run | undefined
113let nextRunId = 1
114/** The volume every ffplay starts at; read back from the store when the session starts. */
115let volume: Volume = DEFAULT_VOLUME
116
117const isCurrent = (id: number, segment: number): boolean => run?.id === id && run.segment === segment
118
119/** Replaces the current run with `fn`'s changes, only while it is still segment `segment` of run `id`. */
120const patchSegment = (id: number, segment: number, fn: (current: Run) => Partial<Run>): Run | undefined => {
121 if (run === undefined || !isCurrent(id, segment)) return undefined
122 run = { ...run, ...fn(run) }
123 return run
124}
125
126/**
127 * The frame file the pane draws: the last one swapped in, or else the segment's current frame, so the
128 * Image is drawn from the first decoded frame on (a swap can only reach an Image already drawn).
129 */
130const pictureOf = (r: Run): string | undefined => r.displayPath ?? (r.shown > 0 ? framePath(r.segDir, r.shown) : undefined)
131
132/** Seconds into the video now: where it was paused, or where the frame on screen sits. */
133const currentPosition = (r: Run): number => (r.isPaused ? r.pausedAtSec : positionAt(r.offsetSec, r.shown, r.fps))
134
135const setPlayer = async ($: EngineInterface, patch: Partial<Player>): Promise<void> => {
136 await update($, playerAtom, player => ({ ...player, ...patch }))
137}
138
139const runInBackground = ($: EngineInterface, work: Promise<unknown>): void => {
140 void work.catch(error => $.ui.log(`video-player: ${String(error)}`, { to: 'debug' }))
141}
142
143/** Runs a command; a command that cannot start reads as a failure with empty output. */
144const runQuietly = async (
145 $: EngineInterface,
146 argv: readonly string[],
147): Promise<{ exitCode: number; stdout: string; stderr: string }> => {
148 try {
149 return await $.process.run(argv)
150 } catch {
151 return { exitCode: 1, stdout: '', stderr: '' }
152 }
153}
154
155/** Ends whatever the current segment (or pause) runs: ffmpeg, ffplay and timers. */
156const halt = (): void => {
157 const halting = run
158 if (halting === undefined) return
159 run = { ...halting, stops: [], stopSound: undefined }
160 halting.stops.forEach(stop => stop())
161 halting.stopSound?.()
162}
163
164/** Ends the run `id` names: everything stops, its frames go, and the pane shows `patch`. */
165const finish = async ($: EngineInterface, id: number, patch: Partial<Player>): Promise<void> => {
166 if (run?.id !== id) return
167 const ending = run
168 halt()
169 run = undefined
170 await runQuietly($, ['rm', '-rf', ending.dir])
171 await setPlayer($, { ...patch, dir: undefined })
172}
173
174const killer = (child: { return: (value: never) => Promise<unknown> }) => () => void child.return(undefined as never).catch(() => {})
175
176/** Plays the audio track from `atSec` through ffplay at the current volume; muted, nothing starts. */
177const playSound = ($: EngineInterface, id: number, segment: number, atSec: number): void => {
178 const current = run
179 const level = audibleLevel(volume)
180 if (current === undefined || !isCurrent(id, segment) || !current.hasSound || level === 0) return
181 const sound = current.sound + 1
182 const child = $.process.spawn({ argv: audioArgv(current.audio, atSec, level) })
183 patchSegment(id, segment, () => ({ stopSound: killer(child), sound }))
184 runInBackground($, followSound($, id, segment, sound, child))
185}
186
187/** Reads ffplay's status output until its first sound clock, which re-anchors the picture clock once. */
188const followSound = async (
189 $: EngineInterface,
190 id: number,
191 segment: number,
192 sound: number,
193 child: AsyncIterable<{ text: string }>,
194): Promise<void> => {
195 let partial = ''
196 let isAligned = false
197 for await (const { text } of child) {
198 if (isAligned) continue
199 const clockSec = firstAudioClock(partial + text)
200 partial = /[^\r\n]*$/.exec(text)?.[0] ?? ''
201 if (clockSec === undefined) continue
202 isAligned = true
203 await alignToSound($, id, segment, sound, clockSec)
204 }
205}
206
207/**
208 * Moves the segment's clock so the frame due now is the one at `clockSec`, where the sound is. A sound
209 * that started late puts the due frame behind the one on screen, and the picture holds until it catches up.
210 */
211const alignToSound = async ($: EngineInterface, id: number, segment: number, sound: number, clockSec: number): Promise<void> => {
212 const now = await $.clock.now()
213 patchSegment(id, segment, r => (r.sound === sound ? { startedAt: now - (clockSec - r.offsetSec) * 1000 } : {}))
214}
215
216/** Puts `next` on screen; the previous segment's frames go once the new segment has a frame up. */
217const blitFrame = async ($: EngineInterface, id: number, segment: number, next: number): Promise<void> => {
218 const current = run
219 if (current === undefined || !isCurrent(id, segment)) return
220 const file = framePath(current.segDir, next)
221 const result = await $.ui.blit({
222 requestId: PANE_ID,
223 key: IMAGE_KEY,
224 source: { file, format: 'rgb', width: current.size.width, height: current.size.height },
225 })
226 const deny = result.deny
227 if (deny !== undefined) {
228 if (ALT_DENY.test(deny)) await finish($, id, { status: 'error', line: NEED_TERMINAL })
229 else patchSegment(id, segment, () => ({ lastDeny: deny }))
230 return
231 }
232 const updated = patchSegment(id, segment, r => ({
233 displayPath: file,
234 shownSinceStats: r.shownSinceStats + 1,
235 prevSegDir: undefined,
236 lastDeny: undefined,
237 }))
238 if (updated !== undefined && current.prevSegDir !== undefined) await runQuietly($, ['rm', '-rf', current.prevSegDir])
239}
240
241/** Swaps in the frame due now; finishes once the decoder is done and the last frame has had its time. */
242const showFrame = async ($: EngineInterface, id: number, segment: number): Promise<void> => {
243 const startedAt = run?.startedAt
244 if (!isCurrent(id, segment) || startedAt === undefined || run === undefined) return
245 const due = dueFrame((await $.clock.now()) - startedAt, run.fps)
246 const latest = run
247 if (latest === undefined || !isCurrent(id, segment)) return
248 if (isFinished(due, latest.decoded, latest.isDecodeDone)) {
249 await finish($, id, { status: 'done', line: DONE_LINE })
250 return
251 }
252 const next = frameToShow(due, latest.decoded)
253 if (next <= latest.shown) return
254 patchSegment(id, segment, () => ({ shown: next }))
255 await blitFrame($, id, segment, next)
256}
257
258const lineFor = (r: Run, fps: number): string => {
259 const line = statusLine({
260 positionSec: currentPosition(r),
261 durationSec: r.durationSec,
262 fps,
263 hasSound: r.hasSound,
264 isPaused: r.isPaused,
265 volume: volumeLabel(volume),
266 })
267 return r.lastDeny === undefined ? line : `${line} · 换帧被拒:${r.lastDeny}`
268}
269
270/** Once a second: removes frames already shown and refreshes the status line and the progress bar. */
271const reportStats = async ($: EngineInterface, id: number, segment: number): Promise<void> => {
272 const current = run
273 if (current === undefined || !isCurrent(id, segment)) return
274 const stale = framesToDelete(current.deletedThrough, current.shown)
275 patchSegment(id, segment, r => ({
276 deletedThrough: Math.max(r.deletedThrough, r.shown - 1),
277 shownSinceStats: 0,
278 lastFps: current.shownSinceStats,
279 }))
280 if (stale.length > 0) await runQuietly($, ['rm', '-f', ...stale.map(index => framePath(current.segDir, index))])
281 if (!isCurrent(id, segment)) return
282 const latest = run
283 if (latest !== undefined) await setPlayer($, { line: lineFor(latest, current.shownSinceStats) })
284}
285
286/** The segment's first frame is on disk: its clock, sound and timers start. */
287const startPlayback = async ($: EngineInterface, id: number, segment: number): Promise<void> => {
288 const startedAt = await $.clock.now()
289 const current = patchSegment(id, segment, () => ({ startedAt }))
290 if (current === undefined) return
291 playSound($, id, segment, current.offsetSec)
292 const frames = $.clock.every(Math.max(1, Math.floor(1000 / current.fps)), () => runInBackground($, showFrame($, id, segment)))
293 const stats = $.clock.every(STATS_MS, () => runInBackground($, reportStats($, id, segment)))
294 patchSegment(id, segment, r => ({ stops: [...r.stops, () => frames.cancel(), () => stats.cancel()] }))
295 await showFrame($, id, segment)
296 // redrawn after the first frame is chosen, so the pane draws the Image the next swaps reach;
297 // skipped when that first swap already ended the playback
298 if (isCurrent(id, segment)) await setPlayer($, { status: 'playing' })
299}
300
301/** Runs the segment's ffmpeg, counting decoded frames from its progress output; playback starts with the first. */
302const decode = async ($: EngineInterface, id: number, segment: number): Promise<void> => {
303 const current = run
304 if (current === undefined || !isCurrent(id, segment)) return
305 const child = $.process.spawn({ argv: decodeArgv(current.video, current.fps, current.segDir, current.size, current.offsetSec) })
306 patchSegment(id, segment, r => ({ stops: [...r.stops, killer(child)] }))
307 let errors = ''
308 for await (const { stream, text } of child) {
309 if (!isCurrent(id, segment)) return
310 if (stream === 'stderr') {
311 errors += text
312 continue
313 }
314 const decoded = lastProgressFrame(text)
315 if (decoded === undefined) continue
316 const updated = patchSegment(id, segment, () => ({ decoded }))
317 if (updated?.startedAt === undefined && decoded >= 1) await startPlayback($, id, segment)
318 }
319 const ended = patchSegment(id, segment, () => ({ isDecodeDone: true }))
320 if (ended !== undefined && ended.decoded === 0) {
321 await finish($, id, { status: 'error', line: `ffmpeg 没有解出画面:${errors.trim().slice(0, ERROR_DETAIL_CHARS)}` })
322 }
323}
324
325/** Ends the current segment and starts a new one at `offsetSec` in a fresh directory. */
326const startSegment = async ($: EngineInterface, id: number, offsetSec: number): Promise<void> => {
327 const before = run
328 if (before?.id !== id) return
329 halt()
330 const segment = before.segment + 1
331 const segDir = `${before.dir}/s${segment}`
332 run = {
333 ...before,
334 isPaused: false,
335 segment,
336 segDir,
337 offsetSec,
338 decoded: 0,
339 isDecodeDone: false,
340 shown: 0,
341 deletedThrough: 0,
342 startedAt: undefined,
343 shownSinceStats: 0,
344 lastFps: before.fps,
345 prevSegDir: before.segment > 0 ? before.segDir : undefined,
346 stops: [],
347 }
348 await runQuietly($, ['mkdir', '-p', segDir])
349 if (!isCurrent(id, segment)) return
350 await setPlayer($, { status: 'decoding' })
351 runInBackground($, decode($, id, segment))
352}
353
354const pause = async ($: EngineInterface): Promise<string> => {
355 const current = run
356 if (current === undefined) return NOT_PLAYING
357 if (current.isPaused) return '已经暂停'
358 const pausedAtSec = currentPosition(current)
359 halt()
360 const touch = $.clock.every(TOUCH_MS, () => runInBackground($, runQuietly($, ['touch', current.dir])))
361 run = { ...(run ?? current), isPaused: true, pausedAtSec, stops: [() => touch.cancel()] }
362 await setPlayer($, { status: 'paused', line: lineFor(run, 0) })
363 return `已暂停在 ${formatClock(pausedAtSec)}`
364}
365
366const resume = async ($: EngineInterface): Promise<string> => {
367 const current = run
368 if (current === undefined) return NOT_PLAYING
369 if (!current.isPaused) return '正在播放'
370 await startSegment($, current.id, current.pausedAtSec)
371 return `从 ${formatClock(current.pausedAtSec)} 继续`
372}
373
374/**
375 * Applies a new volume and remembers it; while the sound plays, only ffplay restarts, from where
376 * the sound is now (the segment's start plus the real time since), so the picture carries on.
377 */
378const applyVolume = async ($: EngineInterface, next: Volume): Promise<string> => {
379 volume = next
380 await $.store.set(VOLUME_KEY, next)
381 const current = run
382 if (current !== undefined && !current.isPaused && current.startedAt !== undefined) {
383 const { id, segment, startedAt } = current
384 const atSec = current.offsetSec + ((await $.clock.now()) - startedAt) / 1000
385 if (isCurrent(id, segment)) {
386 run?.stopSound?.()
387 patchSegment(id, segment, () => ({ stopSound: undefined }))
388 playSound($, id, segment, atSec)
389 }
390 }
391 const latest = run
392 if (latest !== undefined) await setPlayer($, { line: lineFor(latest, latest.isPaused ? 0 : latest.lastFps) })
393 return volumeLabel(next)
394}
395
396const togglePause = async ($: EngineInterface): Promise<string> => (run?.isPaused === true ? resume($) : pause($))
397
398const seek = async ($: EngineInterface, target: SeekTarget): Promise<string> => {
399 const current = run
400 if (current === undefined) return NOT_PLAYING
401 const to = seekTarget(target, currentPosition(current), current.durationSec)
402 await startSegment($, current.id, to)
403 return `跳到 ${formatClock(to)}`
404}
405
406/** Stops whatever plays now; its frames are removed. */
407const stopCurrent = async ($: EngineInterface): Promise<void> => {
408 if (run !== undefined) await finish($, run.id, IDLE)
409}
410
411/** A fresh playback directory under the temp root, or undefined when it cannot be made. */
412const makePlaybackDir = async ($: EngineInterface): Promise<string | undefined> => {
413 const root = frameRoot(await $.env.get('TMPDIR'))
414 await runQuietly($, ['mkdir', '-p', root])
415 const made = await runQuietly($, ['mktemp', '-d', `${root}/play.XXXXXX`])
416 const dir = made.stdout.trim()
417 return made.exitCode === 0 && dir.startsWith(`${root}/`) ? dir : undefined
418}
419
420const readProbe = async ($: EngineInterface, input: Media): Promise<Probe | undefined> => {
421 const probed = await runQuietly($, probeArgv(input))
422 return probed.exitCode === 0 ? parseProbe(probed.stdout) : undefined
423}
424
425/** What to play, or the one line saying why it cannot be played. */
426type Opened = { kind: 'ok'; video: Media; audio: Media; probe: Probe } | { kind: 'error'; text: string }
427
428const refuse = (text: string): Opened => ({ kind: 'error', text })
429
430const openFile = async ($: EngineInterface, path: string): Promise<Opened> => {
431 const media = plainMedia(path)
432 const probe = await readProbe($, media)
433 return probe === undefined ? refuse(`读不了这个视频:${path}`) : { kind: 'ok', video: media, audio: media, probe }
434}
435
436/** A direct link plays as it is; a web page is resolved by yt-dlp into its picture and sound addresses. */
437const openLink = async ($: EngineInterface, url: string, tools: Tools): Promise<Opened> => {
438 const direct = plainMedia(url)
439 const probe = await readProbe($, direct)
440 if (probe !== undefined) return probe.durationSec > 0 ? { kind: 'ok', video: direct, audio: direct, probe } : refuse(NO_LIVE)
441 if (!tools['yt-dlp']) return refuse(NEED_YTDLP)
442 const answer = await runQuietly($, ytdlpArgv(url))
443 if (answer.exitCode !== 0) return refuse(`${CANNOT_RESOLVE}:${answer.stderr.trim().slice(0, ERROR_DETAIL_CHARS)}`)
444 const resolved = parseYtdlp(answer.stdout)
445 if (resolved === undefined) return refuse(CANNOT_RESOLVE)
446 if (resolved.isLive) return refuse(NO_LIVE)
447 const probed = await readProbe($, resolved.video)
448 if (probed === undefined) return refuse(`读不了这个视频:${url}`)
449 if (probed.durationSec <= 0) return refuse(NO_LIVE)
450 const hasAudioTrack = resolved.audio.url !== resolved.video.url || probed.hasAudioTrack
451 return { kind: 'ok', video: resolved.video, audio: resolved.audio, probe: { ...probed, hasAudioTrack } }
452}
453
454const findTools = async ($: EngineInterface): Promise<Tools> => parseWhich((await runQuietly($, whichArgv())).stdout)
455
456const play = async ($: EngineInterface, path: string, fps: number): Promise<string> => {
457 await stopCurrent($)
458 const tools = await findTools($)
459 if (!tools.ffmpeg || !tools.ffprobe) return NEED_FFMPEG
460 const opened = isUrl(path) ? await openLink($, path, tools) : await openFile($, path)
461 if (opened.kind === 'error') return opened.text
462 const { probe } = opened
463 const dir = await makePlaybackDir($)
464 if (dir === undefined) return '建不了临时目录,没有开始播放'
465 const id = nextRunId++
466 run = {
467 id,
468 video: opened.video,
469 audio: opened.audio,
470 fps,
471 dir,
472 size: frameSize(probe.width, probe.height),
473 durationSec: probe.durationSec,
474 hasSound: tools.ffplay && probe.hasAudioTrack,
475 isPaused: false,
476 pausedAtSec: 0,
477 segment: 0,
478 segDir: dir,
479 offsetSec: 0,
480 decoded: 0,
481 isDecodeDone: false,
482 shown: 0,
483 deletedThrough: 0,
484 shownSinceStats: 0,
485 lastFps: fps,
486 sound: 0,
487 stops: [],
488 }
489 const decoding: Player = { status: 'decoding', dir, width: probe.width, height: probe.height, line: DECODING_LINE }
490 await update($, playerAtom, () => decoding)
491 await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
492 await startSegment($, id, 0)
493 return `开始播放 ${probe.width}×${probe.height} @ ${fps} fps`
494}
495
496/** Removes playback directories an interrupted session left behind. */
497const removeLeftovers = async ($: EngineInterface): Promise<void> => {
498 const root = frameRoot(await $.env.get('TMPDIR'))
499 const stale = parseStale((await runQuietly($, staleArgv(root))).stdout, root)
500 if (stale.length > 0) await runQuietly($, ['rm', '-rf', ...stale])
501}
502
503export const register: Register = on => {
504 on('session.start', async ($, e, next) => {
505 await $.command.register({
506 name: 'video',
507 description: '在侧边面板播放视频:/video <绝对路径|链接> [fps] · pause · resume · seek <位置> · volume <0–100> · mute · stop',
508 })
509 volume = readVolume(await $.store.get(VOLUME_KEY))
510 runInBackground($, removeLeftovers($))
511 return next(e)
512 })
513
514 on('command.run', { command: 'video' }, async ($, e) => {
515 const args = parseArgs(e.args)
516 switch (args.kind) {
517 case 'play':
518 return { text: await play($, args.path, args.fps) }
519 case 'pause':
520 return { text: await pause($) }
521 case 'resume':
522 return { text: await resume($) }
523 case 'seek':
524 return { text: await seek($, args.target) }
525 case 'volume':
526 return { text: await applyVolume($, setLevel(volume, args.level)) }
527 case 'mute':
528 return { text: await applyVolume($, toggleMute(volume)) }
529 case 'stop':
530 await stopCurrent($)
531 await update($, playerAtom, () => IDLE)
532 await $.ui.close({ id: PANE_ID })
533 return { text: '已停止' }
534 case 'usage':
535 return { text: USAGE }
536 }
537 })
538
539 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
540 const { Box, Button, Text } = $.ui.resolve(e)
541 if (e.surface !== 'terminal') return <Text dimColor>{TERMINAL_ONLY}</Text>
542 const player = await read($, playerAtom)
543 const current = run
544 const picture = current === undefined ? undefined : pictureOf(current)
545 if (player.dir === undefined || current === undefined || picture === undefined) {
546 return <Text dimColor>{player.line}</Text>
547 }
548 const { Image } = $.ui.resolve(e)
549 const columns = e.props.bodyColumns
550 const cells = progressCells(currentPosition(current), current.durationSec, columns)
551 const act = (work: Promise<unknown>) => runInBackground($, work)
552 return (
553 <Box flexDirection="column">
554 <Image
555 key={IMAGE_KEY}
556 source={{ file: picture, format: 'rgb', width: current.size.width, height: current.size.height }}
557 columns={columns}
558 rows={rowsFor(columns, player.width, player.height)}
559 alt="视频画面"
560 />
561 <Box key="progress" flexDirection="row">
562 {cells.map((cell, i) => (
563 <Button
564 key={`seek-${i}`}
565 plain
566 dimColor={!cell.isPlayed}
567 label={cell.isPlayed ? PLAYED_CELL : UNPLAYED_CELL}
568 onPress={() => act(seek($, { kind: 'absolute', sec: cell.targetSec }))}
569 />
570 ))}
571 </Box>
572 <Box key="controls" flexDirection="row" gap={1}>
573 <Button key="toggle" hotkey="p" onPress={() => act(togglePause($))}>
574 {current.isPaused ? '▶ 播放' : '⏸ 暂停'}
575 </Button>
576 <Button key="back" hotkey="h" onPress={() => act(seek($, { kind: 'relative', sec: -STEP_SEC }))}>
577 « 10s
578 </Button>
579 <Button key="forward" hotkey="l" onPress={() => act(seek($, { kind: 'relative', sec: STEP_SEC }))}>
580 10s »
581 </Button>
582 {current.hasSound && (
583 <Button key="quieter" hotkey="j" onPress={() => act(applyVolume($, stepVolume(volume, -1)))}>
584 🔉
585 </Button>
586 )}
587 {current.hasSound && (
588 <Button key="louder" hotkey="k" onPress={() => act(applyVolume($, stepVolume(volume, 1)))}>
589 🔊
590 </Button>
591 )}
592 {current.hasSound && (
593 <Button key="mute" hotkey="m" onPress={() => act(applyVolume($, toggleMute(volume)))}>
594 {volume.isMuted ? '🔈' : '🔇'}
595 </Button>
596 )}
597 <Text dimColor wrap="truncate-end">
598 {player.line}
599 </Text>
600 </Box>
601 </Box>
602 )
603 })
604}
605hooks/cleanup.ts 25 lines1// Pure planning for frame directories: where they live and which ones an earlier session left behind.
2
3const ROOT_NAME = 'cc-moyu-video'
4const FALLBACK_TMPDIR = '/tmp'
5/** A playing directory changes every second; one untouched this long was left by an interrupted session. */
6export const STALE_MINUTES = 10
7
8/** The root every playback's directory sits under, inside the temp dir. */
9export const frameRoot = (tmpdir: string | undefined): string =>
10 `${(tmpdir ?? FALLBACK_TMPDIR).replace(/\/+$/, '')}/${ROOT_NAME}`
11
12export const staleArgv = (root: string): readonly string[] => [
13 'find', root, '-mindepth', '1', '-maxdepth', '1', '-type', 'd', '-mmin', `+${STALE_MINUTES}`,
14]
15
16/** The direct children of `root` that find printed; anything else is dropped so a removal never leaves the root. */
17export const parseStale = (stdout: string, root: string): readonly string[] =>
18 stdout
19 .split('\n')
20 .map(line => line.trim())
21 .filter(line => {
22 const name = line.slice(root.length + 1)
23 return line.startsWith(`${root}/`) && /^[\w.-]+$/.test(name) && name !== '.' && name !== '..'
24 })
25hooks/plan.ts 136 lines1// Pure planning: command arguments, the ffmpeg / ffprobe / ffplay command lines, and reading their output.
2
3import { parseSeek } from './timeline'
4import type { SeekTarget } from './timeline'
5import { headersArgs, isUrl } from './source'
6import type { Media } from './source'
7import { parseLevel } from './volume'
8
9export const DEFAULT_FPS = 15
10export const MAX_FPS = 60
11/** Decoded frame width in pixels; the terminal scales the picture to its box. */
12export const FRAME_WIDTH = 640
13/** Seconds ffmpeg may run ahead of playback before it is held to real time. */
14export const DECODE_LEAD_SEC = 2
15export const TOOLS = ['ffmpeg', 'ffprobe', 'ffplay', 'yt-dlp'] as const
16
17export type Tool = (typeof TOOLS)[number]
18export type Tools = Readonly<Record<Tool, boolean>>
19export type PlayArgs =
20 | { kind: 'play'; path: string; fps: number }
21 | { kind: 'seek'; target: SeekTarget }
22 | { kind: 'volume'; level: number }
23 | { kind: 'stop' | 'pause' | 'resume' | 'mute' | 'usage' }
24export type Probe = { width: number; height: number; durationSec: number; hasAudioTrack: boolean }
25export type FrameSize = { width: number; height: number }
26
27const USAGE: PlayArgs = { kind: 'usage' }
28
29const unquote = (text: string): string => text.replace(/^(["'])(.*)\1$/, '$2')
30
31/** `/video <absolute path | http(s) link> [fps]`: a path may hold spaces, so a trailing whole number is the fps. */
32export const parseArgs = (args: string): PlayArgs => {
33 const text = args.trim()
34 if (text === 'stop' || text === 'pause' || text === 'resume' || text === 'mute') return { kind: text }
35 if (text === 'volume' || text.startsWith('volume ')) {
36 const level = parseLevel(text.slice('volume'.length))
37 return level === undefined ? USAGE : { kind: 'volume', level }
38 }
39 if (text.startsWith('seek ')) {
40 const target = parseSeek(text.slice('seek '.length))
41 return target === undefined ? USAGE : { kind: 'seek', target }
42 }
43 const match = /^(.*?)(?:\s+(\d+))?$/.exec(text)
44 const path = unquote((match?.[1] ?? '').trim())
45 const fps = match?.[2] === undefined ? DEFAULT_FPS : Number(match[2])
46 if (!path.startsWith('/') && !isUrl(path)) return USAGE
47 if (fps < 1 || fps > MAX_FPS) return USAGE
48 return { kind: 'play', path, fps }
49}
50
51export const whichArgv = (): readonly string[] => ['which', ...TOOLS]
52
53/** `which` prints a resolved path per tool it found; a tool is present when some line ends with `/<tool>`. */
54export const parseWhich = (stdout: string): Tools => {
55 const lines = stdout.split('\n').map(line => line.trim())
56 const has = (tool: Tool): boolean => lines.some(line => line.endsWith(`/${tool}`))
57 return { ffmpeg: has('ffmpeg'), ffprobe: has('ffprobe'), ffplay: has('ffplay'), 'yt-dlp': has('yt-dlp') }
58}
59
60export const probeArgv = (input: Media): readonly string[] => [
61 'ffprobe', '-v', 'error', '-show_entries', 'stream=codec_type,width,height:format=duration', '-of', 'json',
62 ...headersArgs(input.headers), input.url,
63]
64
65/** ffmpeg's http options to pick a dropped connection back up, for network inputs only. */
66const reconnectArgs = (input: Media): readonly string[] =>
67 isUrl(input.url) ? ['-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5'] : []
68
69type ProbeStream = { codec_type?: unknown; width?: unknown; height?: unknown }
70
71const isPositive = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0
72
73/** The first video stream's size, the duration and whether an audio track exists; undefined when it is not a video. */
74export const parseProbe = (stdout: string): Probe | undefined => {
75 let parsed: unknown
76 try {
77 parsed = JSON.parse(stdout)
78 } catch {
79 return undefined
80 }
81 const body = parsed as { streams?: unknown; format?: { duration?: unknown } }
82 const streams: readonly ProbeStream[] = Array.isArray(body.streams) ? body.streams : []
83 const video = streams.find(stream => stream.codec_type === 'video')
84 if (video === undefined || !isPositive(video.width) || !isPositive(video.height)) return undefined
85 const durationSec = Number(body.format?.duration)
86 return {
87 width: video.width,
88 height: video.height,
89 durationSec: Number.isFinite(durationSec) && durationSec > 0 ? durationSec : 0,
90 hasAudioTrack: streams.some(stream => stream.codec_type === 'audio'),
91 }
92}
93
94/** `-ss <seconds>` for a segment that starts past the beginning, to the millisecond. */
95const seekArgs = (offsetSec: number): readonly string[] =>
96 offsetSec > 0 ? ['-ss', String(Math.round(offsetSec * 1000) / 1000)] : []
97
98/** The decoded frame: FRAME_WIDTH wide, the height kept to the aspect and rounded to an even number. */
99export const frameSize = (width: number, height: number): FrameSize => ({
100 width: FRAME_WIDTH,
101 height: Math.max(2, 2 * Math.round((FRAME_WIDTH * height) / width / 2)),
102})
103
104/**
105 * Picture only, held to playback speed after a short head start, each frame written as raw RGB
106 * so the terminal draws it without decoding; the size is given exactly so every file matches it.
107 */
108export const decodeArgv = (input: Media, fps: number, dir: string, size: FrameSize, offsetSec = 0): readonly string[] => [
109 'ffmpeg', '-hide_banner', '-loglevel', 'error', '-nostdin', ...seekArgs(offsetSec),
110 '-readrate', '1', '-readrate_initial_burst', String(DECODE_LEAD_SEC),
111 ...reconnectArgs(input), ...headersArgs(input.headers), '-i', input.url, '-an', '-sn',
112 '-vf', `fps=${fps},scale=${size.width}:${size.height}`,
113 '-progress', 'pipe:1', '-f', 'image2', '-c:v', 'rawvideo', '-pix_fmt', 'rgb24', '-y', `${dir}/%06d.rgb`,
114]
115
116/** Sound only; at `info` ffplay prints its status line, whose first number is the sound clock. */
117export const audioArgv = (input: Media, offsetSec: number, level: number): readonly string[] => [
118 'ffplay', '-nodisp', '-autoexit', '-loglevel', 'info', '-vn', '-sn', '-volume', String(level), ...seekArgs(offsetSec),
119 ...headersArgs(input.headers), input.url,
120]
121
122/** The sound clock in the first ffplay status line that has one (` 99.97 M-A: ...`); `nan` lines have none. */
123export const firstAudioClock = (text: string): number | undefined => {
124 const match = /(?:^|[\r\n])\s*(\d+(?:\.\d+)?) M-A:/.exec(text)
125 return match === null ? undefined : Number(match[1])
126}
127
128/** The last `frame=N` in a piece of ffmpeg's progress output, if any. */
129export const lastProgressFrame = (text: string): number | undefined => {
130 const matches = [...text.matchAll(/^frame=(\d+)$/gm)]
131 const last = matches[matches.length - 1]
132 return last === undefined ? undefined : Number(last[1])
133}
134
135export const framePath = (dir: string, index: number): string => `${dir}/${String(index).padStart(6, '0')}.rgb`
136hooks/source.ts 63 lines1// Pure network-source logic: which inputs are links, the yt-dlp command line and reading its answer.
2
3/** An address ffmpeg, ffprobe and ffplay read, with the request headers it needs (none for a local file). */
4export type Media = Readonly<{ url: string; headers: Readonly<Record<string, string>> }>
5export type Resolved = Readonly<{ video: Media; audio: Media; isLive: boolean }>
6
7/** Short side of the picture asked for: frames are decoded 640 wide, so more is wasted bandwidth. */
8export const MAX_SHORT_SIDE = 480
9
10const URL_PATTERN = /^https?:\/\/\S+$/
11
12export const isUrl = (text: string): boolean => URL_PATTERN.test(text)
13
14/** A local path or a direct link, read as it is with no request headers. */
15export const plainMedia = (url: string): Media => ({ url, headers: {} })
16
17/** One video (no playlist), picture plus sound or one combined format, short side up to 480, H.264 first. */
18export const ytdlpArgv = (url: string): readonly string[] => [
19 'yt-dlp', '-j', '--no-playlist', '--no-warnings', '-f', 'bv*+ba/b', '-S', `res:${MAX_SHORT_SIDE},vcodec:avc`, '--', url,
20]
21
22type Format = { url?: unknown; vcodec?: unknown; acodec?: unknown; http_headers?: unknown }
23
24/** Only string-valued headers are kept. */
25const readHeaders = (value: unknown): Record<string, string> =>
26 typeof value === 'object' && value !== null
27 ? Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))
28 : {}
29
30const mediaOf = (format: Format | undefined): Media | undefined =>
31 format !== undefined && typeof format.url === 'string' && isUrl(format.url)
32 ? { url: format.url, headers: readHeaders(format.http_headers) }
33 : undefined
34
35const hasCodec = (codec: unknown): boolean => typeof codec === 'string' && codec !== 'none'
36
37/** yt-dlp's `-j` answer as the picture and sound to read; undefined when it names no usable http(s) address. */
38export const parseYtdlp = (stdout: string): Resolved | undefined => {
39 let parsed: unknown
40 try {
41 parsed = JSON.parse(stdout)
42 } catch {
43 return undefined
44 }
45 if (typeof parsed !== 'object' || parsed === null) return undefined
46 const info = parsed as Format & { requested_formats?: unknown; is_live?: unknown }
47 const isLive = info.is_live === true
48 const formats: readonly Format[] = Array.isArray(info.requested_formats) ? info.requested_formats : []
49 if (formats.length === 0) {
50 const media = mediaOf(info)
51 return media === undefined ? undefined : { video: media, audio: media, isLive }
52 }
53 const video = mediaOf(formats.find(format => hasCodec(format.vcodec)))
54 const audio = mediaOf(formats.find(format => hasCodec(format.acodec) && !hasCodec(format.vcodec))) ?? video
55 return video === undefined || audio === undefined ? undefined : { video, audio, isLive }
56}
57
58/** `-headers` with each header on its own CRLF-ended line, or nothing when there are none. */
59export const headersArgs = (headers: Readonly<Record<string, string>>): readonly string[] => {
60 const lines = Object.entries(headers).map(([name, value]) => `${name}: ${value}\r\n`)
61 return lines.length === 0 ? [] : ['-headers', lines.join('')]
62}
63hooks/timeline.ts 91 lines1// Pure playback arithmetic: which frame is due, when playback ends, the status line and what to delete.
2
3export const DONE_LINE = '播放完毕'
4const NO_SOUND = '无声音'
5const PAUSED_MARK = '⏸'
6/** The latest a seek may land: a second before the end, so the decoder still has a frame to give. */
7const END_MARGIN_SEC = 1
8/** A terminal cell is about twice as tall as it is wide. */
9const CELL_ASPECT = 2
10
11/** The 1-based frame due `elapsedMs` into playback. */
12export const dueFrame = (elapsedMs: number, fps: number): number => Math.floor((elapsedMs * fps) / 1000) + 1
13
14/** The due frame, or the last decoded one while the decoder is behind; 0 before any frame exists. */
15export const frameToShow = (due: number, decoded: number): number => Math.min(due, decoded)
16
17export const isFinished = (due: number, decoded: number, isDecodeDone: boolean): boolean =>
18 isDecodeDone && decoded > 0 && due > decoded
19
20/** Seconds into the video at a 1-based frame. */
21export const positionSec = (frame: number, fps: number): number => Math.max(0, frame - 1) / fps
22
23/** Seconds into the video at a frame of a segment that started at `offsetSec`. */
24export const positionAt = (offsetSec: number, frame: number, fps: number): number => offsetSec + positionSec(frame, fps)
25
26export type SeekTarget = { kind: 'absolute' | 'relative'; sec: number }
27
28/** `/video seek` argument: `mm:ss` or whole seconds jump there, `+N` / `-N` move by N seconds. */
29export const parseSeek = (text: string): SeekTarget | undefined => {
30 const trimmed = text.trim()
31 const relative = /^([+-])(\d+)$/.exec(trimmed)
32 if (relative !== null) return { kind: 'relative', sec: (relative[1] === '-' ? -1 : 1) * Number(relative[2]) }
33 const clock = /^(\d+):([0-5]\d)$/.exec(trimmed)
34 if (clock !== null) return { kind: 'absolute', sec: Number(clock[1]) * 60 + Number(clock[2]) }
35 return /^\d+$/.test(trimmed) ? { kind: 'absolute', sec: Number(trimmed) } : undefined
36}
37
38/** Where a seek lands, kept between the start and a second before the end (unbounded when the length is unknown). */
39export const seekTarget = (target: SeekTarget, currentSec: number, durationSec: number): number => {
40 const wanted = target.kind === 'absolute' ? target.sec : currentSec + target.sec
41 const latest = durationSec > 0 ? Math.max(0, durationSec - END_MARGIN_SEC) : Number.POSITIVE_INFINITY
42 return Math.min(Math.max(0, wanted), latest)
43}
44
45export type ProgressCell = { isPlayed: boolean; targetSec: number }
46
47/** One cell per column: whether playback has passed it, and where a press on it jumps. */
48export const progressCells = (positionSec: number, durationSec: number, count: number): readonly ProgressCell[] => {
49 const played = durationSec > 0 ? Math.round((count * positionSec) / durationSec) : 0
50 return Array.from({ length: count }, (_, i) => ({
51 isPlayed: i < played,
52 targetSec: durationSec > 0 ? (i / count) * durationSec : 0,
53 }))
54}
55
56const twoDigits = (value: number): string => String(value).padStart(2, '0')
57
58export const formatClock = (seconds: number): string => {
59 const whole = Math.floor(seconds)
60 return `${twoDigits(Math.floor(whole / 60))}:${twoDigits(whole % 60)}`
61}
62
63export type StatusInput = {
64 positionSec: number
65 durationSec: number
66 fps: number
67 hasSound: boolean
68 isPaused?: boolean
69 /** The volume label, shown last when the video has sound. */
70 volume?: string
71}
72
73/**
74 * `01:05 / 14:48 · 15 fps · 音量 60%` while playing; `⏸ 01:05 / 14:48 · …` while paused;
75 * `无声音` last in place of the volume when silent.
76 */
77export const statusLine = ({ positionSec, durationSec, fps, hasSound, isPaused = false, volume }: StatusInput): string => {
78 const clock = `${formatClock(positionSec)} / ${formatClock(durationSec)}`
79 const parts = isPaused ? [`${PAUSED_MARK} ${clock}`] : [clock, `${fps} fps`]
80 const sound = hasSound ? (volume === undefined ? [] : [volume]) : [NO_SOUND]
81 return [...parts, ...sound].join(' · ')
82}
83
84/** Frames after `deletedThrough` and before the one on screen: shown already, so safe to remove. */
85export const framesToDelete = (deletedThrough: number, shown: number): readonly number[] =>
86 Array.from({ length: Math.max(0, shown - 1 - deletedThrough) }, (_, i) => deletedThrough + 1 + i)
87
88/** Rows a picture needs at `columns` wide to keep its aspect. */
89export const rowsFor = (columns: number, width: number, height: number): number =>
90 Math.max(1, Math.round((columns * height) / width / CELL_ASPECT))
91hooks/volume.ts 44 lines1// Pure volume logic: the stored setting, its steps and the status-line label.
2
3export const MAX_LEVEL = 100
4export const VOLUME_STEP = 10
5export const VOLUME_KEY = 'volume'
6
7export type Volume = Readonly<{ level: number; isMuted: boolean }>
8
9export const DEFAULT_VOLUME: Volume = { level: MAX_LEVEL, isMuted: false }
10
11const isLevel = (value: unknown): value is number =>
12 typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= MAX_LEVEL
13
14/** The stored setting, or 100% unmuted when it is missing or malformed. */
15export const readVolume = (stored: unknown): Volume => {
16 const value = stored as { level?: unknown; isMuted?: unknown } | null | undefined
17 if (typeof value !== 'object' || value === null) return DEFAULT_VOLUME
18 if (!isLevel(value.level) || typeof value.isMuted !== 'boolean') return DEFAULT_VOLUME
19 return { level: value.level, isMuted: value.isMuted }
20}
21
22/** `/video volume` argument: a whole number from 0 to 100. */
23export const parseLevel = (text: string): number | undefined => {
24 const trimmed = text.trim()
25 if (!/^\d+$/.test(trimmed)) return undefined
26 const level = Number(trimmed)
27 return isLevel(level) ? level : undefined
28}
29
30export const setLevel = (_volume: Volume, level: number): Volume => ({ level, isMuted: false })
31
32/** One step louder (+1) or quieter (-1), kept within 0–100; a step unmutes. */
33export const stepVolume = (volume: Volume, direction: 1 | -1): Volume => ({
34 level: Math.min(MAX_LEVEL, Math.max(0, volume.level + direction * VOLUME_STEP)),
35 isMuted: false,
36})
37
38export const toggleMute = (volume: Volume): Volume => ({ ...volume, isMuted: !volume.isMuted })
39
40/** The level ffplay plays at; 0 means no sound is started. */
41export const audibleLevel = (volume: Volume): number => (volume.isMuted ? 0 : volume.level)
42
43export const volumeLabel = (volume: Volume): string => (volume.isMuted ? '静音' : `音量 ${volume.level}%`)
44types/index.d.ts 21 lines1export type PlayerStatus = 'idle' | 'decoding' | 'playing' | 'paused' | 'done' | 'error'
2
3/** What the pane draws from; frame-by-frame counters stay out of state so a swap never redraws. */
4export type Player = {
5 status: PlayerStatus
6 /** This playback's frame directory; absent once it is removed. */
7 dir?: string
8 width: number
9 height: number
10 /** One-line status or error under the picture. */
11 line: string
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 'video-player': {
17 player: Player
18 }
19 }
20}
21