Plays Bad Apple!! in a pane or the band above the prompt, with sound

Plays Bad Apple!! inside Claude Code, as a mod (a plugin of function hooks). The video plays in the band above the prompt, or in a side pane, with the song in sync.
▘▀▌▙█…) over 2x2 quarters, drawn as one Raster. Black and white by default; with gray on, each cell takes two of 32 gray levels, the best two-tone fit to how much of each quarter is lit, so edges are antialiased$.ui.blit, frame picked from the wall clock so video stays in sync with the song| Command | What it does |
|---|---|
/bad-apple | Play in the band above the prompt (default) |
/bad-apple pane | Play in a pane (Esc closes it) |
/bad-apple pause / resume | Pause or resume |
/bad-apple restart | Play again from 0:00, in the same place |
/bad-apple gray | Turn grayscale antialiasing on or off (off by default; remembered across sessions) |
/bad-apple stop | Stop |
The band has Pause/Resume (p), Restart (r), Gray (g) and Stop (s) buttons; the pane has all but Stop (Esc closes it).
Sound plays through afplay, so only on macOS. Other systems get the video only.
The video frames and the song are not in this repo. Build them on your machine. You need ffmpeg, python3, and yt-dlp (or uvx, which runs the latest yt-dlp).
git clone https://github.com/narze/bad-apple-claude-mod
cd bad-apple-claude-mod
./scripts/download-video.sh # yt-dlp: YouTube -> work/bad-apple.webm
./scripts/build-assets.sh # work file -> assets/
claude --plugin-dir "$PWD"
| Script | What it does |
|---|---|
scripts/download-video.sh [url] [--force] | Downloads the PV from YouTube with yt-dlp (480p or less) to the working file work/bad-apple.<ext>. Skips the download when the file is there; --force downloads again. Default URL: https://www.youtube.com/watch?v=FtutLA63Cp8 |
scripts/build-assets.sh [url] [--force] | Runs download-video.sh (same arguments), then makes 192x144 1-bit frames at 30 fps in assets/frames-*.bin and a 96 kbit/s mono MP3 in assets/bad-apple.mp3 |
If YouTube refuses the download (HTTP Error 403), update yt-dlp, or install uv so the script uses uvx yt-dlp@latest. work/ and assets/ are git-ignored.
claude plugin validate .
claude plugin test .
npx -p typescript tsc -p . # after Claude Code has loaded the mod once (it writes .claude-plugin/types/)
| File | Role |
|---|---|
hooks/register.tsx | Command, player, band and pane drawing |
hooks/frames.ts | Pure helpers: grid fit, frame encoding, MP3 offset |
hooks/bad-apple.test.ts | Tests (claude plugin test) |
types/index.d.ts | $.state contract (mode, isPaused, isMono) |
scripts/download-video.sh | Downloads the working video file to work/ |
scripts/build-assets.sh | Builds assets/ from the working file |
Bad Apple!! is a song from Touhou Project by ZUN, arranged by Alstroemeria Records, with the shadow-art PV by Anira. This repo has no part of the video or the song.
The code is MIT. The MIT license does not cover the video or the song.
hooks/register.tsx 296 lines1import { atom, read, update } from 'claude-code'
2import type { Register, Timer } from 'claude-code'
3
4import type { Mode } from '../types'
5import { clockText, encodeFrame, encodeProgress, fitGrid, joinChunks, songFrom, type Grid, type Meta } from './frames.ts'
6
7const PANE = 'bad-apple'
8const RASTER = 'video'
9const PROGRESS = 'progress'
10const BAND_ROWS = 16
11// Cells beside the video in the band: title, time, bar, buttons.
12const INFO_COLUMNS = 20
13// assets/bad-apple.mp3 is constant 96 kbit/s.
14const SONG_BYTES_PER_SECOND = 12000
15
16// Where it plays and whether it is paused; the band and the pane redraw
17// when either changes.
18const mode = atom({ plugin: 'bad-apple', key: 'mode' } as const, 'off' as Mode)
19const isPaused = atom({ plugin: 'bad-apple', key: 'isPaused' } as const, false)
20// Grayscale off (the default): black and white only. Kept across sessions in $.store.
21const isMono = atom({ plugin: 'bad-apple', key: 'isMono' } as const, true)
22
23// Player state lives in the module: a reload drops the timer anyway, so
24// a fresh load with a stopped player is the right state after one.
25let meta: Meta | undefined
26let bits: Uint8Array | undefined
27let song: Uint8Array | undefined
28let timer: Timer | undefined
29let audio: AbortController | undefined
30let frame = 0
31// Song position in ms while paused; `startedAt` maps wall clock to it while playing.
32let position = 0
33let startedAt = 0
34// The Rasters the ticker repaints: set by whichever site drew them last.
35let target: { requestId: string; grid: Grid; bar: number } | undefined
36let lastProgress = ''
37// Mirrors `isMono` for the ticker, which repaints without reading state.
38let mono = true
39
40function silence() {
41 timer?.cancel()
42 timer = undefined
43 audio?.abort()
44 audio = undefined
45}
46
47function halt() {
48 silence()
49 target = undefined
50 lastProgress = ''
51 position = 0
52 frame = 0
53}
54
55type Action = 'band' | 'pane' | 'pause' | 'resume' | 'toggle' | 'restart' | 'gray' | 'stop'
56
57// The player's verbs, made in session.start so a command and a Button press
58// both reach them; set again on every load.
59let player: ((action: Action) => Promise<string>) | undefined
60
61export const register: Register = on => {
62 on('session.start', async ($, e, next) => {
63 await $.command.register({
64 name: 'bad-apple',
65 description: 'Play Bad Apple!! above the prompt (pane, pause, resume, restart, gray, stop)',
66 argumentHint: '[pane|pause|resume|restart|gray|stop]',
67 })
68 mono = (await $.store.get('isMono').catch(() => undefined)) !== false
69 await update($, isMono, () => mono)
70
71 player = async asked => {
72 if (asked === 'gray') {
73 mono = !(await read($, isMono))
74 await update($, isMono, () => mono)
75 await $.store.set('isMono', mono)
76 return mono ? 'Grayscale off.' : 'Grayscale on.'
77 }
78 const where = await read($, mode)
79 const paused = await read($, isPaused)
80 const action = asked === 'toggle'
81 ? (paused ? 'resume' : 'pause')
82 : asked === 'restart' && where === 'off' ? 'band' : asked
83
84 if (action === 'pause') {
85 if (where === 'off' || paused) return 'Bad Apple!! is not playing.'
86 position = (await $.clock.now()) - startedAt
87 silence()
88 await update($, isPaused, () => true)
89 $.ui.status(`⏸ Bad Apple!! ${clockText(position / 1000)}`)
90 return 'Bad Apple!! paused.'
91 }
92
93 if (action === 'resume' && (where === 'off' || !paused)) return 'Bad Apple!! is not paused.'
94
95 // Resume and restart keep playing where it is; the rest pick a site anew.
96 const isSameSite = action === 'resume' || action === 'restart'
97 if (action === 'restart') {
98 silence()
99 position = 0
100 frame = 0
101 lastProgress = ''
102 }
103
104 if (!isSameSite) {
105 // Switch off the old site first, so its close does not stop the new one.
106 halt()
107 await update($, isPaused, () => false)
108 await update($, mode, () => 'off')
109 await $.ui.close({ id: PANE })
110 $.ui.status(undefined)
111 if (action === 'stop') return 'Bad Apple!! stopped.'
112 }
113
114 if (!meta || !bits || !song) {
115 const dir = `${$.plugin.root}/assets`
116 if (!(await $.fs.exists(`${dir}/meta.json`))) {
117 return `No frames yet: run ${$.plugin.root}/scripts/build-assets.sh first.`
118 }
119 const info: Meta = JSON.parse(await $.fs.read(`${dir}/meta.json`))
120 const parts: Uint8Array[] = []
121 for (let i = 0; i < info.chunks; i++) {
122 const { base64 } = await $.fs.read(`${dir}/frames-${i}.bin`, { as: 'bytes' })
123 parts.push(Uint8Array.fromBase64(base64))
124 }
125 const mp3 = await $.fs.read(`${dir}/bad-apple.mp3`, { as: 'bytes' })
126 bits = joinChunks(parts)
127 song = Uint8Array.fromBase64(mp3.base64)
128 meta = info
129 }
130 const video = meta
131 const data = bits
132 const total = clockText(video.frames / video.fps)
133
134 if (!isSameSite) {
135 await update($, mode, () => action)
136 if (action === 'pane') {
137 await $.ui.open({ id: PANE, title: 'Bad Apple!!', focus: true, closeOnEscape: true, rows: 40, columns: 98 })
138 }
139 }
140 await update($, isPaused, () => false)
141
142 audio = new AbortController()
143 const clip = { base64: songFrom(song, position, SONG_BYTES_PER_SECOND).toBase64(), mime: 'audio/mpeg' }
144 void $.audio.play(clip, { signal: audio.signal }).catch(() => {
145 $.ui.toast('bad-apple: no sound here, video only')
146 })
147 startedAt = (await $.clock.now()) - position
148
149 // Frame index comes from the wall clock, so video stays in sync with
150 // the song even when a tick is late.
151 const ticker: Timer = $.clock.every(16, async () => {
152 if (timer !== ticker) return
153 const elapsed = (await $.clock.now()) - startedAt
154 const due = Math.floor((elapsed * video.fps) / 1000)
155 if (due >= video.frames) {
156 halt()
157 await update($, mode, () => 'off')
158 await $.ui.close({ id: PANE })
159 $.ui.status(undefined)
160 $.ui.toast('Bad Apple!! - fin')
161 return
162 }
163 if (due === frame) return
164 frame = due
165 if (frame % video.fps === 0) {
166 $.ui.status(`▶ Bad Apple!! ${clockText(elapsed / 1000)} / ${total}`)
167 }
168 if (target) {
169 const { requestId, grid, bar } = target
170 await $.ui.blit({ requestId, key: RASTER, cells: encodeFrame(video, data, frame, grid, mono) })
171 const progress = encodeProgress(frame / video.fps, video.frames / video.fps, bar)
172 if (progress !== lastProgress) {
173 lastProgress = progress
174 await $.ui.blit({ requestId, key: PROGRESS, cells: progress })
175 }
176 }
177 })
178 timer = ticker
179
180 if (action === 'resume') return 'Bad Apple!! resumed.'
181 if (action === 'restart') return 'Bad Apple!! restarted.'
182 return action === 'band'
183 ? 'Playing Bad Apple!! above the prompt - /bad-apple stop ends it.'
184 : 'Playing Bad Apple!! - Esc closes the pane.'
185 }
186
187 return next(e)
188 })
189
190 on('command.run', { command: 'bad-apple' }, async ($, e) => {
191 const arg = e.args.trim() || 'band'
192 const actions: Action[] = ['band', 'pane', 'pause', 'resume', 'toggle', 'restart', 'gray', 'stop']
193 const action = actions.find(one => one === arg)
194 if (!action) return { text: `Unknown "${arg}". Use /bad-apple [pane|pause|resume|restart|gray|stop].` }
195 if (!player) return { text: 'bad-apple: not ready yet, try again.' }
196 return { text: await player(action) }
197 })
198
199 on('ui.close', async ($, e, next) => {
200 if (e.id === PANE && (await read($, mode)) === 'pane') {
201 halt()
202 await update($, isPaused, () => false)
203 await update($, mode, () => 'off')
204 $.ui.status(undefined)
205 }
206 return next(e)
207 })
208
209 on('session.end', async ($, e, next) => {
210 halt()
211 return next(e)
212 })
213
214 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
215 const where = await read($, mode)
216 if (where !== 'band' || e.props.hasSurvey || e.surface !== 'terminal' || !meta || !bits) {
217 return next(e)
218 }
219 const paused = await read($, isPaused)
220 mono = await read($, isMono)
221 const { Box, Text, Button, Raster } = $.ui.resolve(e)
222 const grid = fitGrid(meta, e.props.bodyColumns - INFO_COLUMNS - 2, Math.min(BAND_ROWS, e.props.maxRows - 1))
223 const progress = encodeProgress(frame / meta.fps, meta.frames / meta.fps, INFO_COLUMNS)
224 target = { requestId: e.requestId, grid, bar: INFO_COLUMNS }
225 lastProgress = progress
226 return (
227 <Box flexDirection="row" gap={2}>
228 <Raster key={RASTER} columns={grid.columns} rows={grid.rows} cells={encodeFrame(meta, bits, frame, grid, mono)} />
229 <Box flexDirection="column">
230 <Text bold>Bad Apple!!</Text>
231 <Text dimColor>{paused ? 'paused' : 'now playing'}</Text>
232 <Text> </Text>
233 <Raster key={PROGRESS} columns={INFO_COLUMNS} rows={2} cells={progress} />
234 <Text> </Text>
235 <Button
236 key="toggle"
237 label={paused ? 'Resume' : 'Pause'}
238 hotkey="p"
239 variant="primary"
240 onPress={() => player?.('toggle')}
241 />
242 <Button
243 key="restart"
244 label="Restart"
245 hotkey="r"
246 onPress={() => player?.('restart')}
247 />
248 <Button
249 key="gray"
250 label={mono ? 'Gray: off' : 'Gray: on'}
251 hotkey="g"
252 onPress={() => player?.('gray')}
253 />
254 <Button
255 key="stop"
256 label="Stop"
257 hotkey="s"
258 onPress={() => player?.('stop')}
259 />
260 </Box>
261 </Box>
262 )
263 })
264
265 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
266 if (e.surface !== 'terminal' || !meta || !bits) {
267 const { Text } = $.ui.resolve(e)
268 return <Text dimColor>{meta ? 'Bad Apple!! plays in the terminal only.' : 'Loading frames…'}</Text>
269 }
270 const paused = await read($, isPaused)
271 mono = await read($, isMono)
272 const { Box, Button, Raster } = $.ui.resolve(e)
273 const grid = fitGrid(meta, e.props.bodyColumns, e.props.scroll.bodyRows - 2)
274 const bar = Math.max(10, Math.min(40, grid.columns - 40))
275 const progress = encodeProgress(frame / meta.fps, meta.frames / meta.fps, bar)
276 target = { requestId: PANE, grid, bar }
277 lastProgress = progress
278 return (
279 <Box flexDirection="column">
280 <Raster key={RASTER} columns={grid.columns} rows={grid.rows} cells={encodeFrame(meta, bits, frame, grid, mono)} />
281 <Box flexDirection="row" gap={2}>
282 <Button
283 key="toggle"
284 label={paused ? 'Resume' : 'Pause'}
285 hotkey="p"
286 onPress={() => player?.('toggle')}
287 />
288 <Button key="restart" label="Restart" hotkey="r" onPress={() => player?.('restart')} />
289 <Button key="gray" label={mono ? 'Gray: off' : 'Gray: on'} hotkey="g" onPress={() => player?.('gray')} />
290 <Raster key={PROGRESS} columns={bar} rows={2} cells={progress} />
291 </Box>
292 </Box>
293 )
294 })
295}
296hooks/frames.ts 181 lines1// Pure helpers: fit the video into a cell grid and encode one frame as
2// Raster cells. Each cell is a solid quadrant glyph over 2x2 quarters with
3// two gray levels: each quarter's gray is the share of its source pixels
4// lit, and the glyph and grays are the best two-tone fit, so edges come
5// out antialiased.
6
7export type Meta = {
8 width: number
9 height: number
10 fps: number
11 frames: number
12 framesPerChunk: number
13 chunks: number
14}
15
16export type Grid = { columns: number; rows: number }
17
18const WHITE = 0xffffff
19const BLACK = 0x000000
20const DEFAULT = 0x01000000
21const TRACK = 0x3a3a3a
22const FULL = 0x2588
23
24// Quadrant glyph by lit quarters: top-left 8, top-right 4, bottom-left 2, bottom-right 1.
25const QUADRANTS = [
26 0x20, 0x2597, 0x2596, 0x2584, 0x259d, 0x2590, 0x259e, 0x259f,
27 0x2598, 0x259a, 0x258c, 0x2599, 0x2580, 0x259c, 0x259b, 0x2588,
28]
29// Gray steps: 32 levels keep the fg/bg pairs within the Raster's 1024.
30const LEVELS = 31
31
32const PATTERNS = [15, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
33
34function gray(share: number): number {
35 const v = Math.round((Math.round(share * LEVELS) * 255) / LEVELS)
36 return (v << 16) | (v << 8) | v
37}
38
39// Biggest grid that keeps the video aspect inside the given room, never
40// more quarters than the video has pixels for. A cell is about twice as
41// tall as wide, so rows = columns * height / width / 2.
42export function fitGrid(meta: Meta, maxColumns: number, maxRows: number): Grid {
43 let columns = Math.max(1, Math.min(meta.width >> 1, maxColumns))
44 let rows = Math.round((columns * meta.height) / meta.width / 2)
45 if (rows > maxRows) {
46 rows = Math.max(1, maxRows)
47 columns = Math.max(1, Math.round((rows * 2 * meta.width) / meta.height))
48 }
49 return { columns: Math.min(columns, 512), rows: Math.max(1, Math.min(rows, 256)) }
50}
51
52// Source pixel ranges [start, end) for each of `count` target pixels over `size`.
53function spans(count: number, size: number): Array<[number, number]> {
54 return Array.from({ length: count }, (_, i) => {
55 const start = Math.min(size - 1, Math.floor((i * size) / count))
56 return [start, Math.max(start + 1, Math.floor(((i + 1) * size) / count))]
57 })
58}
59
60// One frame from the packed 1-bit data (MSB first, row-major) as base64 cells.
61// At 96x36 cells a 192x144 video gives each quarter a 1x2 pixel block;
62// smaller grids average bigger blocks, so nothing is upscaled. With
63// `isMono` each quarter is cut to black or white: no gray, crisp edges.
64export function encodeFrame(meta: Meta, bits: Uint8Array, frame: number, grid: Grid, isMono = false): string {
65 const { width, height } = meta
66 const base = frame * ((width * height) >> 3)
67 const source = (x: number, y: number) => {
68 const i = y * width + x
69 return ((bits[base + (i >> 3)] ?? 0) >> (7 - (i & 7))) & 1
70 }
71 const xs = spans(grid.columns * 2, width)
72 const ys = spans(grid.rows * 2, height)
73 const share = (qx: number, qy: number) => {
74 const [x0, x1] = xs[qx] ?? [0, 1]
75 const [y0, y1] = ys[qy] ?? [0, 1]
76 let on = 0
77 for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) on += source(x, y)
78 const lit = on / ((x1 - x0) * (y1 - y0))
79 return isMono ? (lit >= 0.5 ? 1 : 0) : lit
80 }
81 const words = new Uint32Array(grid.columns * grid.rows * 3)
82 const quarter = [0, 0, 0, 0]
83 let w = 0
84 for (let r = 0; r < grid.rows; r++) {
85 for (let c = 0; c < grid.columns; c++) {
86 // top-left, top-right, bottom-left, bottom-right: bits 8, 4, 2, 1
87 quarter[0] = share(c * 2, r * 2)
88 quarter[1] = share(c * 2 + 1, r * 2)
89 quarter[2] = share(c * 2, r * 2 + 1)
90 quarter[3] = share(c * 2 + 1, r * 2 + 1)
91 let best = 15
92 let bestError = Infinity
93 let fg = 0
94 let bg = 0
95 // The cell as one tone (15) first, then every split into a lit set and
96 // the rest; a split wins only when it fits strictly better.
97 for (const pattern of PATTERNS) {
98 let litSum = 0
99 let litCount = 0
100 let restSum = 0
101 for (let q = 0; q < 4; q++) {
102 if (pattern & (8 >> q)) {
103 litSum += quarter[q] ?? 0
104 litCount++
105 } else {
106 restSum += quarter[q] ?? 0
107 }
108 }
109 const litMean = litSum / litCount
110 const restMean = litCount === 4 ? litMean : restSum / (4 - litCount)
111 let error = 0
112 for (let q = 0; q < 4; q++) {
113 const mean = pattern & (8 >> q) ? litMean : restMean
114 error += ((quarter[q] ?? 0) - mean) ** 2
115 }
116 if (error < bestError - 1e-9) {
117 best = pattern
118 bestError = error
119 fg = litMean
120 bg = restMean
121 }
122 }
123 // The brighter part is the glyph, so a shape reads the same each way.
124 if (best !== 15 && fg < bg) {
125 best = 15 - best
126 ;[fg, bg] = [bg, fg]
127 }
128 words[w++] = QUADRANTS[best] ?? 0x2588
129 words[w++] = gray(fg)
130 words[w++] = gray(best === 15 ? fg : bg)
131 }
132 }
133 return new Uint8Array(words.buffer).toBase64()
134}
135
136// Two rows of `columns` cells: "m:ss / m:ss", then a bar filled by eighths.
137export function encodeProgress(elapsed: number, total: number, columns: number): string {
138 const words = new Uint32Array(columns * 2 * 3)
139 const text = `${clockText(elapsed)} / ${clockText(total)}`
140 for (let c = 0; c < columns; c++) {
141 words.set([text.codePointAt(c) ?? 0x20, DEFAULT, DEFAULT], c * 3)
142 }
143 const eighths = Math.round(Math.min(1, Math.max(0, elapsed / total)) * columns * 8)
144 for (let c = 0; c < columns; c++) {
145 const lit = Math.min(8, Math.max(0, eighths - c * 8))
146 const glyph = lit === 8 ? FULL : lit === 0 ? 0x20 : 0x2590 - lit
147 words.set([glyph, WHITE, TRACK], (columns + c) * 3)
148 }
149 return new Uint8Array(words.buffer).toBase64()
150}
151
152export function joinChunks(parts: Uint8Array[]): Uint8Array {
153 const all = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
154 let at = 0
155 for (const p of parts) {
156 all.set(p, at)
157 at += p.length
158 }
159 return all
160}
161
162export function clockText(seconds: number): string {
163 const s = Math.max(0, Math.floor(seconds))
164 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
165}
166
167// Where the MP3 frames start: after the ID3v2 tag, whose size is syncsafe.
168export function id3End(bytes: Uint8Array): number {
169 if (bytes[0] !== 0x49 || bytes[1] !== 0x44 || bytes[2] !== 0x33) return 0
170 const size = (((bytes[6] ?? 0) & 0x7f) << 21) | (((bytes[7] ?? 0) & 0x7f) << 14) |
171 (((bytes[8] ?? 0) & 0x7f) << 7) | ((bytes[9] ?? 0) & 0x7f)
172 return 10 + size
173}
174
175// The song from `ms` on: a constant-bitrate MP3 cut at a byte offset; the
176// decoder finds the next frame by itself.
177export function songFrom(bytes: Uint8Array, ms: number, bytesPerSecond: number): Uint8Array {
178 const at = id3End(bytes) + Math.floor((ms / 1000) * bytesPerSecond)
179 return bytes.subarray(Math.min(at, bytes.length))
180}
181types/index.d.ts 8 lines1export type Mode = 'off' | 'pane' | 'band'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'bad-apple': { mode: Mode; isPaused: boolean; isMono: boolean }
6 }
7}
8