SLOPSHOPPER

video-player

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

panecommandprocesstimer
v0.1.0MITupdated 2026-10-16androidZzT/cc-moyu-kit/mods/video-player
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · video-player
│ ┃ video-player ✕ › fix the failing auth test and add an audit log call │ ┃ 用法:/video <绝对路径|http(s) 链接> [fps │ ┃ 1–60,默认 15] · /video pause | resume | ⏺ Read(src/auth.ts) │ ┃ seek <mm:ss|秒|+N|-N> | volume <0–100> | ⎿ Read 6 lines │ ┃ mute | stop ⏺ 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 │ │ › /video │ ⎿ video-player: 用法:/video <绝对路径|http(s) 链接> [fps 1–60,默认 15] · /vi │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · video-player
用法:/video <绝对路径|http(s) 链接> [fps 1–60,默认 15] · /video pause | resume | seek <mm:ss|秒|+N|-N> | volume <0–100> | mute | stop
README

video-player

在 Claude Code 侧边面板里播放本地视频或网络视频,agent 跑长任务的时候看点东西,不用切出终端。

  • 画面:本机 ffmpeg 把视频解成 640 宽的原始 RGB 帧,终端直接读帧文件显示、不用解码,面板其余部分不重绘
  • 声音:本机 ffplay 无窗口播放音轨,和第一帧同时开始
  • 按时间推进:画面按真实时间走,解码跟不上时停在最后一帧等待,不会加速或跳回
  • 播放控制:暂停/继续、前后跳 10 秒、可点击的进度条
  • 网络视频:直链直接读;B 站、YouTube 等网页链接用本机 yt-dlp 解析出画面和声音地址
  • 音画对齐:声音真正出声后,画面时钟对齐到声音的位置,网络起播慢也不会音画错开
  • 单独的音量:不动系统音量,0–100% 和静音,跨会话记住
  • 占用可控:解码只领先播放约 2 秒,播过的帧每秒删除一次,15 fps 下临时目录常驻约 30 MB,和片长无关
  • 低打扰:不抢输入焦点,不弹窗;出错时面板或命令回复里给一行提示

要求

要求说明
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 静音。快捷键只在面板有焦点时生效,不影响输入框
  • 状态行依次是当前位置、总时长、最近一秒实际显示的帧数、音量(或「静音」);暂停时以「⏸」开头。实际帧数明显低于设定值,说明终端绘制跟不上,可以把 fps 调低
  • 调音量只重启声音、画面不动:ffplay 运行中改不了音量,所以从当前位置以新音量重新起播,每次调整声音会断约 0.1–0.3 秒;最高 100%,不能放大。视频没有声音时不显示音量按钮

网络视频

  • 先用 ffprobe 直接读链接;读不出视频(网页)再调用 yt-dlp,命令回复会等解析完成,通常几秒
  • yt-dlp 给出的画面地址、声音地址分别交给 ffmpeg 和 ffplay,请求头(Referer、User-Agent)一起带上;B 站不带会 403
  • 画面和声音各一条连接,480p 下合计约 1 Mbps
  • 解析出的地址几小时后过期,过期后跳转会失败,重新 /video 一次即可
  • 网速跟不上时画面停在最后一帧等,声音有自己的缓冲会继续;下次继续或跳转时重新对齐

不支持:直播、需要登录或 cookie 的内容、播放列表、字幕、倍速。

临时文件

  • 帧写在 $TMPDIR/cc-moyu-video/play.XXXXXX/s<段号>/ 下:每次播放一个目录,每次继续或跳转开一个新段,新段出画面后删除旧段
  • 原始 RGB 帧每帧约 520 KB(640×272),比 PNG 大,但终端不用解码
  • 播完、/video stop、换片时删除本次目录
  • 会话启动时删除 10 分钟内没动过的目录:那是之前的会话被重载或退出打断后留下的。正在播放的目录每秒都有变化,暂停中的目录每分钟 touch 一次,都不会被误删

开发

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 子进程。

Source 7 files
hooks/register.tsx 605 lines
1import { 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}
605
hooks/cleanup.ts 25 lines
1// 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    })
25
hooks/plan.ts 136 lines
1// 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`
136
hooks/source.ts 63 lines
1// 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}
63
hooks/timeline.ts 91 lines
1// 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))
91
hooks/volume.ts 44 lines
1// 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}%`)
44
types/index.d.ts 21 lines
1export 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