klaudio 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js CHANGED
@@ -1,999 +1,1205 @@
1
- import React, { useState, useEffect, useCallback } from "react";
2
- import { render, Box, Text, useInput, useApp } from "ink";
3
- import SelectInput from "ink-select-input";
4
- import Spinner from "ink-spinner";
5
- import { PRESETS, EVENTS } from "./presets.js";
6
- import { playSoundWithCancel, getWavDuration } from "./player.js";
7
- import { getAvailableGames } from "./scanner.js";
8
- import { install, uninstall } from "./installer.js";
9
- import { getVgmstreamPath, findPackedAudioFiles, extractToWav } from "./extractor.js";
10
- import { getCachedExtraction, cacheExtraction, categorizeLooseFiles, getCategories, sortFilesByPriority } from "./cache.js";
11
- import { basename, dirname } from "node:path";
12
- import { tmpdir } from "node:os";
13
- import { join } from "node:path";
14
-
15
- const MAX_PLAY_SECONDS = 10;
16
-
17
- const h = React.createElement;
18
-
19
- // ── Screens ─────────────────────────────────────────────────────
20
- const SCREEN = {
21
- SCOPE: 0,
22
- PRESET: 1,
23
- PREVIEW: 2,
24
- SCANNING: 3,
25
- GAME_PICK: 4,
26
- GAME_SOUNDS: 5,
27
- EXTRACTING: 6,
28
- CONFIRM: 7,
29
- INSTALLING: 8,
30
- DONE: 9,
31
- };
32
-
33
- const isUninstallMode = process.argv.includes("--uninstall") || process.argv.includes("--remove");
34
-
35
- // ── Header component ────────────────────────────────────────────
36
- const Header = () =>
37
- h(Box, { flexDirection: "column", marginBottom: 1 },
38
- h(Text, { bold: true, color: "cyan" }, " 🔊 Klonk"),
39
- h(Text, { dimColor: true }, isUninstallMode
40
- ? " Remove sound effects from Claude Code"
41
- : " Add sound effects to your Claude Code sessions"),
42
- );
43
-
44
- const NavHint = ({ back = true, extra = "" }) =>
45
- h(Box, { marginTop: 1 },
46
- h(Text, { dimColor: true },
47
- (back ? " esc back" : "") +
48
- (extra ? (back ? "" : " ") + extra : "")
49
- ),
50
- );
51
-
52
- // ── Screen: Scope ───────────────────────────────────────────────
53
- const ScopeScreen = ({ onNext }) => {
54
- const items = [
55
- { label: "Global (~/.claude) — sounds in all projects", value: "global" },
56
- { label: "This project (.claude/) — project-specific", value: "project" },
57
- ];
58
- return h(Box, { flexDirection: "column" },
59
- h(Text, { bold: true }, " Where should sounds be installed?"),
60
- h(Box, { marginLeft: 2 },
61
- h(SelectInput, { items, onSelect: (item) => onNext(item.value) }),
62
- ),
63
- );
64
- };
65
-
66
- // ── Screen: Preset ──────────────────────────────────────────────
67
- const PresetScreen = ({ onNext, onBack }) => {
68
- useInput((_, key) => { if (key.escape) onBack(); });
69
-
70
- const items = [
71
- ...Object.entries(PRESETS).map(([id, p]) => ({
72
- label: `${p.icon} ${p.name} — ${p.description}`,
73
- value: id,
74
- })),
75
- { label: "🎯 Scan local games — find sounds from Steam/Epic", value: "_scan" },
76
- { label: "📁 Custom files — provide your own sound files", value: "_custom" },
77
- ];
78
-
79
- return h(Box, { flexDirection: "column" },
80
- h(Text, { bold: true }, " Choose a sound preset:"),
81
- h(Box, { marginLeft: 2 },
82
- h(SelectInput, { items, onSelect: (item) => onNext(item.value) }),
83
- ),
84
- h(NavHint, { back: true }),
85
- );
86
- };
87
-
88
- // ── Screen: Preview ─────────────────────────────────────────────
89
- const PreviewScreen = ({ presetId, sounds, onAccept, onBack, onUpdateSound }) => {
90
- const preset = PRESETS[presetId];
91
- const eventIds = Object.keys(EVENTS);
92
- const [currentEvent, setCurrentEvent] = useState(0);
93
- const [playing, setPlaying] = useState(false);
94
- const [elapsed, setElapsed] = useState(0);
95
- const [durations, setDurations] = useState({});
96
- const cancelRef = React.useRef(null);
97
-
98
- const eventId = eventIds[currentEvent];
99
- const eventInfo = EVENTS[eventId];
100
- const soundFile = sounds[eventId];
101
-
102
- const stopPlayback = useCallback(() => {
103
- if (cancelRef.current) { cancelRef.current(); cancelRef.current = null; }
104
- setPlaying(false);
105
- setElapsed(0);
106
- }, []);
107
-
108
- // Fetch durations for all sound files
109
- useEffect(() => {
110
- for (const [eid, path] of Object.entries(sounds)) {
111
- if (path && !durations[eid]) {
112
- getWavDuration(path).then((dur) => {
113
- if (dur != null) setDurations((d) => ({ ...d, [eid]: dur }));
114
- });
115
- }
116
- }
117
- }, [sounds]);
118
-
119
- // Auto-play when current event changes (with debounce)
120
- useEffect(() => {
121
- if (!soundFile) return;
122
- stopPlayback();
123
- const timer = setTimeout(() => {
124
- setPlaying(true);
125
- const { promise, cancel } = playSoundWithCancel(soundFile);
126
- cancelRef.current = cancel;
127
- promise.catch(() => {}).finally(() => {
128
- cancelRef.current = null;
129
- setPlaying(false);
130
- setElapsed(0);
131
- });
132
- }, 150);
133
- return () => {
134
- clearTimeout(timer);
135
- if (cancelRef.current) {
136
- cancelRef.current();
137
- cancelRef.current = null;
138
- }
139
- };
140
- }, [currentEvent]);
141
-
142
- useInput((_, key) => {
143
- if (key.escape) {
144
- if (playing) {
145
- stopPlayback();
146
- } else {
147
- onBack();
148
- }
149
- } else if (key.leftArrow || key.upArrow) {
150
- if (currentEvent > 0) {
151
- stopPlayback();
152
- setCurrentEvent((i) => i - 1);
153
- }
154
- } else if (key.rightArrow || key.downArrow) {
155
- if (currentEvent < eventIds.length - 1) {
156
- stopPlayback();
157
- setCurrentEvent((i) => i + 1);
158
- }
159
- } else if (key.return) {
160
- stopPlayback();
161
- onAccept(sounds);
162
- }
163
- });
164
-
165
- // Elapsed timer while playing
166
- useEffect(() => {
167
- if (!playing) return;
168
- setElapsed(0);
169
- const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
170
- return () => clearInterval(interval);
171
- }, [playing]);
172
-
173
- const stepLabel = `(${currentEvent + 1}/${eventIds.length})`;
174
- const dur = durations[eventId];
175
- const durLabel = dur != null ? ` (${dur > MAX_PLAY_SECONDS ? MAX_PLAY_SECONDS : dur}s)` : "";
176
-
177
- return h(Box, { flexDirection: "column" },
178
- h(Text, { bold: true }, ` ${preset.icon} ${preset.name}`),
179
- h(Text, { dimColor: true }, ` ${preset.description}`),
180
- h(Box, { marginTop: 1, flexDirection: "column" },
181
- ...eventIds.map((eid, i) => {
182
- const d = durations[eid];
183
- const dStr = d != null ? ` (${d > MAX_PLAY_SECONDS ? MAX_PLAY_SECONDS : d}s)` : "";
184
- return h(Text, { key: eid, marginLeft: 2,
185
- color: i === currentEvent ? "cyan" : i < currentEvent ? "green" : "white",
186
- bold: i === currentEvent,
187
- },
188
- i < currentEvent ? " ✓ " : i === currentEvent ? " ▸ " : " ",
189
- `${EVENTS[eid].name}: `,
190
- sounds[eid] ? `${basename(sounds[eid])}${dStr}` : "(skipped)",
191
- );
192
- }),
193
- ),
194
- h(Box, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: playing ? "green" : "cyan", paddingX: 2, paddingY: 0, marginLeft: 2, marginRight: 2 },
195
- h(Text, { bold: true, color: playing ? "green" : "cyan" },
196
- `${eventInfo.name} ${stepLabel}`,
197
- ),
198
- h(Text, { dimColor: true },
199
- soundFile
200
- ? `Sound: ${basename(soundFile)}${durLabel}`
201
- : "No sound file selected",
202
- ),
203
- h(Text, { dimColor: true },
204
- `Triggers: ${eventInfo.description}`,
205
- ),
206
- playing
207
- ? h(Box, { marginTop: 1 },
208
- h(Text, { color: "green", bold: true }, h(Spinner, { type: "dots" })),
209
- h(Text, { color: "green", bold: true }, ` Now playing: ${basename(soundFile)} ${elapsed}s / ${MAX_PLAY_SECONDS}s max`),
210
- )
211
- : null,
212
- ),
213
- h(NavHint, { back: true, extra: "↑↓ switch events • enter accept all" }),
214
- );
215
- };
216
-
217
- // ── Screen: Game Pick (with progressive background scanning) ────
218
- const GamePickScreen = ({ onNext, onExtract, onBack }) => {
219
- const [games, setGames] = useState([]);
220
- const [scanning, setScanning] = useState(true);
221
- const [scanStatus, setScanStatus] = useState("Discovering game directories...");
222
- const [filter, setFilter] = useState("");
223
-
224
- // Start scanning on mount, add games progressively
225
- useEffect(() => {
226
- let cancelled = false;
227
- getAvailableGames(
228
- (progress) => {
229
- if (cancelled) return;
230
- if (progress.phase === "dirs") {
231
- setScanStatus(`Scanning ${progress.dirs.length} directories...`);
232
- } else if (progress.phase === "scanning") {
233
- setScanStatus(`Scanning: ${progress.game}`);
234
- }
235
- },
236
- (game) => {
237
- if (cancelled) return;
238
- // Add each game as it's found (only if it has audio or can extract)
239
- setGames((prev) => {
240
- if (prev.some((g) => g.name === game.name)) return prev;
241
- const next = [...prev, game];
242
- // Sort: playable first, then extractable, then others
243
- next.sort((a, b) => {
244
- if (a.hasAudio !== b.hasAudio) return a.hasAudio ? -1 : 1;
245
- if (a.canExtract !== b.canExtract) return a.canExtract ? -1 : 1;
246
- return a.name.localeCompare(b.name);
247
- });
248
- return next;
249
- });
250
- },
251
- ).then(() => {
252
- if (!cancelled) setScanning(false);
253
- }).catch(() => {
254
- if (!cancelled) setScanning(false);
255
- });
256
- return () => { cancelled = true; };
257
- }, []);
258
-
259
- useInput((input, key) => {
260
- if (key.escape) {
261
- if (filter) setFilter("");
262
- else onBack();
263
- } else if (key.backspace || key.delete) {
264
- setFilter((f) => f.slice(0, -1));
265
- } else if (input && !key.ctrl && !key.meta && input.length === 1 && input.charCodeAt(0) >= 32) {
266
- setFilter((f) => f + input);
267
- }
268
- });
269
-
270
- const gamesWithAudio = games.filter((g) => g.hasAudio);
271
- const extractable = games.filter((g) => !g.hasAudio && g.canExtract);
272
- const noAudio = games.filter((g) => !g.hasAudio && !g.canExtract);
273
-
274
- const allItems = [
275
- ...gamesWithAudio.map((g) => ({
276
- key: `play:${g.name}`,
277
- label: `${g.name} (${g.fileCount} audio files)`,
278
- value: `play:${g.name}`,
279
- })),
280
- ...extractable.map((g) => ({
281
- key: `extract:${g.name}`,
282
- label: `${g.name} (${g.packedAudioCount} packed extract with vgmstream)`,
283
- value: `extract:${g.name}`,
284
- })),
285
- ];
286
-
287
- const filterLower = filter.toLowerCase();
288
- const items = filter
289
- ? allItems.filter((i) => i.label.toLowerCase().includes(filterLower))
290
- : allItems;
291
-
292
- return h(Box, { flexDirection: "column" },
293
- scanning
294
- ? h(Box, { marginLeft: 2 },
295
- h(Text, { color: "cyan" }, h(Spinner, { type: "dots" })),
296
- h(Text, null, ` ${scanStatus}`),
297
- games.length > 0
298
- ? h(Text, { color: "green" }, ` (${games.length} found)`)
299
- : null,
300
- )
301
- : h(Text, { bold: true, marginLeft: 2 },
302
- ` Found ${games.length} game(s):`,
303
- ),
304
- filter
305
- ? h(Box, { marginLeft: 4 },
306
- h(Text, { color: "yellow" }, "Filter: "),
307
- h(Text, { bold: true }, filter),
308
- h(Text, { dimColor: true }, ` (${items.length} match${items.length !== 1 ? "es" : ""})`),
309
- )
310
- : items.length > 0
311
- ? h(Text, { dimColor: true, marginLeft: 4 }, "Type to filter... (select a game while scan continues)")
312
- : null,
313
- items.length > 0
314
- ? h(Box, { marginLeft: 2 },
315
- h(SelectInput, {
316
- items,
317
- limit: 15,
318
- onSelect: (item) => {
319
- const [type, ...rest] = item.value.split(":");
320
- const name = rest.join(":");
321
- if (type === "extract") onExtract(name, games);
322
- else onNext(name, games);
323
- },
324
- }),
325
- )
326
- : !scanning
327
- ? h(Text, { color: "yellow", marginLeft: 4 }, "No games with usable audio found.")
328
- : null,
329
- noAudio.length > 0 && !filter && !scanning
330
- ? h(Box, { flexDirection: "column", marginTop: 1, marginLeft: 4 },
331
- h(Text, { dimColor: true },
332
- `${noAudio.length} game(s) with no extractable audio:`,
333
- ),
334
- h(Text, { dimColor: true },
335
- noAudio.map((g) => g.name).join(", "),
336
- ),
337
- )
338
- : null,
339
- h(NavHint, { back: true }),
340
- );
341
- };
342
-
343
- // ── Screen: Game Sound Picker ───────────────────────────────────
344
- // Three-phase: category pick file pick → preview/accept/repick
345
- const CATEGORY_LABELS = {
346
- all: "All sounds", ambient: "Ambient", music: "Music", sfx: "SFX",
347
- ui: "UI", voice: "Voice / Dialogue", creature: "Creatures / Animals", other: "Other",
348
- };
349
- const CATEGORY_ICONS = {
350
- voice: "🗣", creature: "🐾", ui: "🖱", sfx: "💥",
351
- ambient: "🌿", music: "🎵", other: "📦", all: "📂",
352
- };
353
-
354
- const GameSoundsScreen = ({ game, onDone, onBack }) => {
355
- const eventIds = Object.keys(EVENTS);
356
- const [currentEvent, setCurrentEvent] = useState(0);
357
- const [selections, setSelections] = useState({});
358
- const [playing, setPlaying] = useState(false);
359
- const [elapsed, setElapsed] = useState(0);
360
- const [highlightedFile, setHighlightedFile] = useState(null);
361
- const [fileDurations, setFileDurations] = useState({});
362
- const [filter, setFilter] = useState("");
363
- const [activeCategory, setActiveCategory] = useState(null); // null = show category picker
364
- const [autoPreview, setAutoPreview] = useState(true);
365
- const cancelRef = React.useRef(null);
366
-
367
- // Determine available categories with counts
368
- const hasCategories = game.files.some((f) => f.category);
369
- const { categories, counts } = hasCategories
370
- ? getCategories(game.files)
371
- : { categories: ["all"], counts: {} };
372
- const meaningfulCats = categories.filter((c) => c !== "all" && (counts[c] || 0) >= 2);
373
- const showCategoryPicker = meaningfulCats.length >= 2;
374
-
375
- // Sort files: voice first, then by priority
376
- const sortedFiles = hasCategories ? sortFilesByPriority(game.files) : game.files;
377
-
378
- // Fetch durations for visible files
379
- useEffect(() => {
380
- for (const f of sortedFiles.slice(0, 50)) {
381
- if (!fileDurations[f.path]) {
382
- getWavDuration(f.path).then((dur) => {
383
- if (dur != null) setFileDurations((d) => ({ ...d, [f.path]: dur }));
384
- });
385
- }
386
- }
387
- }, [game.files]);
388
-
389
- // Filter files by category
390
- const categoryFiles = activeCategory && activeCategory !== "all"
391
- ? sortedFiles.filter((f) => f.category === activeCategory).slice(0, 50)
392
- : sortedFiles.slice(0, 50);
393
-
394
- // Stop current playback helper
395
- const stopPlayback = useCallback(() => {
396
- if (cancelRef.current) {
397
- cancelRef.current();
398
- cancelRef.current = null;
399
- }
400
- setPlaying(false);
401
- setElapsed(0);
402
- }, []);
403
-
404
- // Auto-preview: play sound when highlighted file changes (with debounce)
405
- useEffect(() => {
406
- if (!autoPreview || !highlightedFile || highlightedFile === "_skip") {
407
- return;
408
- }
409
- // Cancel previous playback immediately
410
- if (cancelRef.current) {
411
- cancelRef.current();
412
- cancelRef.current = null;
413
- }
414
- setPlaying(false);
415
- setElapsed(0);
416
- // Debounce: wait 150ms before starting playback so scrubbing doesn't spam
417
- const timer = setTimeout(() => {
418
- setPlaying(true);
419
- setElapsed(0);
420
- const { promise, cancel } = playSoundWithCancel(highlightedFile);
421
- cancelRef.current = cancel;
422
- promise.catch(() => {}).finally(() => {
423
- cancelRef.current = null;
424
- setPlaying(false);
425
- setElapsed(0);
426
- });
427
- }, 150);
428
- return () => {
429
- clearTimeout(timer);
430
- if (cancelRef.current) {
431
- cancelRef.current();
432
- cancelRef.current = null;
433
- }
434
- };
435
- }, [highlightedFile, autoPreview]);
436
-
437
- useInput((input, key) => {
438
- if (key.escape) {
439
- if (playing) {
440
- stopPlayback();
441
- } else if (filter) {
442
- setFilter("");
443
- } else if (activeCategory !== null && showCategoryPicker) {
444
- stopPlayback();
445
- setActiveCategory(null);
446
- } else {
447
- stopPlayback();
448
- onBack();
449
- }
450
- } else if (input === "p" && !key.ctrl && !key.meta) {
451
- // Toggle auto-preview
452
- setAutoPreview((prev) => {
453
- if (prev) stopPlayback(); // turning off — stop current sound
454
- return !prev;
455
- });
456
- } else if (activeCategory !== null) {
457
- if (key.backspace || key.delete) {
458
- setFilter((f) => f.slice(0, -1));
459
- } else if (input && input !== "p" && !key.ctrl && !key.meta && input.length === 1 && input.charCodeAt(0) >= 32) {
460
- setFilter((f) => f + input);
461
- }
462
- }
463
- });
464
-
465
- // Elapsed timer while playing
466
- useEffect(() => {
467
- if (!playing) return;
468
- setElapsed(0);
469
- const interval = setInterval(() => {
470
- setElapsed((e) => e + 1);
471
- }, 1000);
472
- return () => clearInterval(interval);
473
- }, [playing]);
474
-
475
- const eventId = eventIds[currentEvent];
476
- const eventInfo = EVENTS[eventId];
477
- const stepLabel = `(${currentEvent + 1}/${eventIds.length})`;
478
-
479
- const advance = useCallback((newSelections) => {
480
- stopPlayback();
481
- if (currentEvent < eventIds.length - 1) {
482
- setCurrentEvent((i) => i + 1);
483
- setHighlightedFile(null);
484
- setActiveCategory(null);
485
- setFilter("");
486
- } else {
487
- onDone(newSelections);
488
- }
489
- }, [currentEvent, eventIds.length, onDone, stopPlayback]);
490
-
491
- const nowPlayingFile = playing && highlightedFile && highlightedFile !== "_skip"
492
- ? highlightedFile : null;
493
-
494
- const headerBox = h(Box, { marginLeft: 2, marginBottom: 1, flexDirection: "column", borderStyle: "round", borderColor: nowPlayingFile ? "green" : "cyan", paddingX: 2 },
495
- h(Text, { bold: true, color: nowPlayingFile ? "green" : "cyan" },
496
- `${game.name} — ${eventInfo.name} ${stepLabel}`,
497
- ),
498
- h(Text, { dimColor: true }, `Triggers: ${eventInfo.description}`),
499
- nowPlayingFile
500
- ? h(Box, null,
501
- h(Text, { color: "green", bold: true }, h(Spinner, { type: "dots" })),
502
- h(Text, { color: "green", bold: true }, ` Now playing: ${basename(nowPlayingFile)} ${elapsed}s / ${MAX_PLAY_SECONDS}s max`),
503
- )
504
- : null,
505
- );
506
-
507
- // Phase 0: Category picker
508
- if (activeCategory === null && showCategoryPicker) {
509
- const catItems = [
510
- ...meaningfulCats.map((cat) => ({
511
- label: `${CATEGORY_ICONS[cat] || "📁"} ${CATEGORY_LABELS[cat] || cat} (${counts[cat]} sounds)`,
512
- value: cat,
513
- })),
514
- { label: `${CATEGORY_ICONS.all} All sounds (${game.files.length})`, value: "all" },
515
- { label: "(skip this event)", value: "_skip" },
516
- ];
517
-
518
- return h(Box, { flexDirection: "column" },
519
- headerBox,
520
- h(Text, { bold: true, marginLeft: 4 }, "Pick a category:"),
521
- h(Box, { marginLeft: 2 },
522
- h(SelectInput, {
523
- items: catItems,
524
- onSelect: (item) => {
525
- if (item.value === "_skip") {
526
- const newSelections = { ...selections };
527
- setSelections(newSelections);
528
- advance(newSelections);
529
- } else {
530
- setActiveCategory(item.value);
531
- }
532
- },
533
- }),
534
- ),
535
- h(NavHint, { back: true }),
536
- );
537
- }
538
-
539
- // Phase 1: Browse and pick files (auto-preview plays on highlight)
540
- const filterLower = filter.toLowerCase();
541
- const allFileItems = categoryFiles.map((f) => {
542
- const dur = fileDurations[f.path];
543
- const durStr = dur != null ? ` (${dur > MAX_PLAY_SECONDS ? MAX_PLAY_SECONDS + "s max" : dur + "s"})` : "";
544
- const catTag = (!activeCategory || activeCategory === "all") && f.category && f.category !== "other"
545
- ? `[${(CATEGORY_LABELS[f.category] || f.category).toUpperCase()}] ` : "";
546
- const name = f.displayName || f.name;
547
- return {
548
- label: `${catTag}${name}${durStr}`,
549
- value: f.path,
550
- };
551
- });
552
-
553
- const filteredFiles = filter
554
- ? allFileItems.filter((i) => i.label.toLowerCase().includes(filterLower))
555
- : allFileItems;
556
-
557
- const fileItems = [
558
- ...filteredFiles,
559
- ...(!filter ? [{ label: "(skip this event)", value: "_skip" }] : []),
560
- ];
561
-
562
- const catLabel = activeCategory && activeCategory !== "all"
563
- ? `${CATEGORY_ICONS[activeCategory] || ""} ${CATEGORY_LABELS[activeCategory] || activeCategory}`
564
- : null;
565
-
566
- return h(Box, { flexDirection: "column" },
567
- headerBox,
568
- catLabel
569
- ? h(Text, { bold: true, color: "cyan", marginLeft: 4 }, catLabel)
570
- : null,
571
- h(Box, { marginLeft: 4 },
572
- h(Text, { color: autoPreview ? "green" : "gray", bold: autoPreview },
573
- autoPreview ? "♫ Auto-preview ON" : "♫ Auto-preview OFF"
574
- ),
575
- h(Text, { dimColor: true }, " (p to toggle)"),
576
- ),
577
- filter
578
- ? h(Box, { marginLeft: 4 },
579
- h(Text, { color: "yellow" }, "Filter: "),
580
- h(Text, { bold: true }, filter),
581
- h(Text, { dimColor: true }, ` (${filteredFiles.length} match${filteredFiles.length !== 1 ? "es" : ""})`),
582
- )
583
- : categoryFiles.length > 15
584
- ? h(Text, { dimColor: true, marginLeft: 4 }, "Type to filter...")
585
- : null,
586
- fileItems.length > 0
587
- ? h(Box, { marginLeft: 2 },
588
- h(SelectInput, {
589
- items: fileItems,
590
- limit: 15,
591
- onHighlight: (item) => {
592
- setHighlightedFile(item.value);
593
- },
594
- onSelect: (item) => {
595
- stopPlayback();
596
- if (item.value === "_skip") {
597
- const newSelections = { ...selections };
598
- setSelections(newSelections);
599
- advance(newSelections);
600
- } else {
601
- // Enter = accept this sound
602
- const newSelections = { ...selections, [eventId]: item.value };
603
- setSelections(newSelections);
604
- advance(newSelections);
605
- }
606
- },
607
- }),
608
- )
609
- : h(Text, { color: "yellow", marginLeft: 4 }, "No matches."),
610
- h(NavHint, { back: true, extra: "enter accept" }),
611
- );
612
- };
613
-
614
- // ── Screen: Extracting ──────────────────────────────────────────
615
- const ExtractingScreen = ({ game, onDone, onBack }) => {
616
- const [status, setStatus] = useState("Checking cache...");
617
- const [extracted, setExtracted] = useState(0);
618
-
619
- useInput((_, key) => { if (key.escape) onBack(); });
620
-
621
- useEffect(() => {
622
- let cancelled = false;
623
-
624
- (async () => {
625
- try {
626
- // Check cache first
627
- const cached = await getCachedExtraction(game.name);
628
- if (cached && cached.files.length > 0) {
629
- setStatus(`Found ${cached.files.length} cached sounds`);
630
- if (!cancelled) {
631
- onDone({
632
- files: cached.files.map((f) => ({
633
- path: f.path,
634
- name: f.name,
635
- displayName: f.displayName,
636
- category: f.category,
637
- dir: dirname(f.path),
638
- })),
639
- categories: cached.categories,
640
- fromCache: true,
641
- });
642
- }
643
- return;
644
- }
645
-
646
- // Get vgmstream-cli (downloads if needed)
647
- setStatus("Getting vgmstream-cli...");
648
- const vgmstream = await getVgmstreamPath((msg) => {
649
- if (!cancelled) setStatus(msg);
650
- });
651
-
652
- // Find packed audio files
653
- setStatus(`Scanning ${game.name} for packed audio...`);
654
- const packedFiles = await findPackedAudioFiles(game.path, 30);
655
-
656
- if (packedFiles.length === 0) {
657
- if (!cancelled) onDone({ files: [], error: "No extractable audio files found" });
658
- return;
659
- }
660
-
661
- setStatus(`Found ${packedFiles.length} files. Extracting...`);
662
-
663
- // Extract to temp dir
664
- const outputDir = join(tmpdir(), "klonk-extract", game.name.replace(/[^a-zA-Z0-9]/g, "_"));
665
- const allOutputs = [];
666
-
667
- for (const file of packedFiles) {
668
- if (cancelled) return;
669
- setStatus(`Extracting: ${file.name}`);
670
- try {
671
- const outputs = await extractToWav(file.path, outputDir, vgmstream);
672
- allOutputs.push(...outputs);
673
- setExtracted(allOutputs.length);
674
- } catch {
675
- // Skip files that fail
676
- }
677
- }
678
-
679
- if (!cancelled) {
680
- // Cache the results with category metadata
681
- const rawFiles = allOutputs.map((p) => ({ path: p, name: basename(p) }));
682
- setStatus("Caching extracted sounds...");
683
- const manifest = await cacheExtraction(game.name, rawFiles, game.path);
684
-
685
- onDone({
686
- files: manifest.files.map((f) => ({
687
- path: f.path,
688
- name: f.name,
689
- displayName: f.displayName,
690
- category: f.category,
691
- dir: dirname(f.path),
692
- })),
693
- categories: manifest.categories,
694
- });
695
- }
696
- } catch (err) {
697
- if (!cancelled) onDone({ files: [], error: err.message });
698
- }
699
- })();
700
-
701
- return () => { cancelled = true; };
702
- }, []);
703
-
704
- return h(Box, { flexDirection: "column" },
705
- h(Box, { marginLeft: 2, flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2 },
706
- h(Text, { bold: true, color: "cyan" }, `Extracting audio from ${game.name}`),
707
- h(Box, { marginTop: 1 },
708
- h(Text, { color: "cyan" }, h(Spinner, { type: "dots" })),
709
- h(Text, null, ` ${status}`),
710
- ),
711
- extracted > 0
712
- ? h(Text, { color: "green" }, ` ${extracted} sound(s) extracted so far`)
713
- : null,
714
- ),
715
- h(NavHint, { back: true }),
716
- );
717
- };
718
-
719
- // ── Screen: Confirm ─────────────────────────────────────────────
720
- const ConfirmScreen = ({ scope, sounds, onConfirm, onBack }) => {
721
- useInput((_, key) => { if (key.escape) onBack(); });
722
-
723
- const items = [
724
- { label: "✓ Yes, install!", value: "yes" },
725
- { label: "✗ No, go back", value: "no" },
726
- ];
727
-
728
- const soundEntries = Object.entries(sounds).filter(([_, path]) => path);
729
-
730
- return h(Box, { flexDirection: "column" },
731
- h(Text, { bold: true, marginLeft: 2 }, " Ready to install:"),
732
- h(Box, { marginTop: 1, flexDirection: "column" },
733
- h(Text, { marginLeft: 4 }, `Scope: ${scope === "global" ? "Global (~/.claude)" : "This project (.claude/)"}`),
734
- ...soundEntries.map(([eid, path]) =>
735
- h(Text, { key: eid, marginLeft: 4 },
736
- `${EVENTS[eid].name} ${basename(path)}`
737
- )
738
- ),
739
- ),
740
- h(Box, { marginTop: 1, marginLeft: 2 },
741
- h(SelectInput, { items, onSelect: (item) => {
742
- if (item.value === "yes") onConfirm();
743
- else onBack();
744
- }}),
745
- ),
746
- h(NavHint, { back: true }),
747
- );
748
- };
749
-
750
- // ── Screen: Installing ──────────────────────────────────────────
751
- const InstallingScreen = ({ scope, sounds, onDone }) => {
752
- useEffect(() => {
753
- const validSounds = {};
754
- for (const [eventId, path] of Object.entries(sounds)) {
755
- if (path) validSounds[eventId] = path;
756
- }
757
- install({ scope, sounds: validSounds }).then(onDone).catch((err) => {
758
- onDone({ error: err.message });
759
- });
760
- }, []);
761
-
762
- return h(Box, { marginLeft: 2 },
763
- h(Text, { color: "cyan" }, h(Spinner, { type: "dots" })),
764
- h(Text, null, " Installing sounds..."),
765
- );
766
- };
767
-
768
- // ── Screen: Done ────────────────────────────────────────────────
769
- const DoneScreen = ({ result }) => {
770
- const { exit } = useApp();
771
-
772
- useEffect(() => {
773
- // Play the "stop" sound as a demo if it was installed
774
- if (result.installedSounds?.stop) {
775
- playSoundWithCancel(result.installedSounds.stop).promise.catch(() => {});
776
- }
777
- const timer = setTimeout(() => exit(), 1500);
778
- return () => clearTimeout(timer);
779
- }, []);
780
-
781
- if (result.error) {
782
- return h(Box, { flexDirection: "column", marginLeft: 2 },
783
- h(Text, { color: "red", bold: true }, " ✗ Installation failed:"),
784
- h(Text, { color: "red" }, ` ${result.error}`),
785
- );
786
- }
787
-
788
- return h(Box, { flexDirection: "column", marginLeft: 2 },
789
- h(Text, { color: "green", bold: true }, " ✓ Sounds installed!"),
790
- h(Box, { marginTop: 1, flexDirection: "column" },
791
- h(Text, null, ` Sound files: ${result.soundsDir}`),
792
- h(Text, null, ` Config: ${result.settingsFile}`),
793
- ),
794
- h(Box, { marginTop: 1, flexDirection: "column" },
795
- h(Text, null, " Your Claude Code sessions will now play sounds for:"),
796
- ...Object.keys(result.installedSounds).map((eventId) =>
797
- h(Text, { key: eventId, color: "green" }, ` • ${EVENTS[eventId].name}`)
798
- ),
799
- ),
800
- h(Box, { marginTop: 1 },
801
- h(Text, { dimColor: true }, " To remove: npx klonk --uninstall"),
802
- ),
803
- );
804
- };
805
-
806
- // ── Uninstall App ───────────────────────────────────────────────
807
- const UninstallApp = () => {
808
- const { exit } = useApp();
809
- const [phase, setPhase] = useState("scope"); // scope | working | done | notfound
810
- const [scope, setScope] = useState(null);
811
-
812
- useEffect(() => {
813
- if (phase === "working" && scope) {
814
- uninstall(scope).then((ok) => {
815
- setPhase(ok ? "done" : "notfound");
816
- setTimeout(() => exit(), 500);
817
- });
818
- }
819
- }, [phase, scope]);
820
-
821
- if (phase === "scope") {
822
- return h(Box, { flexDirection: "column" },
823
- h(Header, null),
824
- h(ScopeScreen, {
825
- onNext: (s) => { setScope(s); setPhase("working"); },
826
- }),
827
- );
828
- }
829
-
830
- if (phase === "working") {
831
- return h(Box, { flexDirection: "column" },
832
- h(Header, null),
833
- h(Box, { marginLeft: 2 },
834
- h(Text, { color: "cyan" }, h(Spinner, { type: "dots" })),
835
- h(Text, null, " Removing sounds..."),
836
- ),
837
- );
838
- }
839
-
840
- if (phase === "done") {
841
- return h(Box, { flexDirection: "column" },
842
- h(Header, null),
843
- h(Text, { color: "green", marginLeft: 2 }, " ✓ Klonk hooks removed."),
844
- );
845
- }
846
-
847
- return h(Box, { flexDirection: "column" },
848
- h(Header, null),
849
- h(Text, { color: "yellow", marginLeft: 2 }, " No Klonk configuration found."),
850
- );
851
- };
852
-
853
- // ── Main Install App ────────────────────────────────────────────
854
- const InstallApp = () => {
855
- const [screen, setScreen] = useState(SCREEN.SCOPE);
856
- const [scope, setScope] = useState(null);
857
- const [presetId, setPresetId] = useState(null);
858
- const [sounds, setSounds] = useState({});
859
- const [selectedGame, setSelectedGame] = useState(null);
860
- const [installResult, setInstallResult] = useState(null);
861
-
862
- const initSoundsFromPreset = useCallback((pid) => {
863
- const preset = PRESETS[pid];
864
- if (preset) setSounds({ ...preset.sounds });
865
- }, []);
866
-
867
- const content = (() => {
868
- switch (screen) {
869
- case SCREEN.SCOPE:
870
- return h(ScopeScreen, {
871
- onNext: (s) => { setScope(s); setScreen(SCREEN.PRESET); },
872
- });
873
-
874
- case SCREEN.PRESET:
875
- return h(PresetScreen, {
876
- onNext: (id) => {
877
- if (id === "_scan") {
878
- setScreen(SCREEN.GAME_PICK);
879
- } else if (id === "_custom") {
880
- const firstPreset = Object.keys(PRESETS)[0];
881
- setPresetId(firstPreset);
882
- initSoundsFromPreset(firstPreset);
883
- setScreen(SCREEN.PREVIEW);
884
- } else {
885
- setPresetId(id);
886
- initSoundsFromPreset(id);
887
- setScreen(SCREEN.PREVIEW);
888
- }
889
- },
890
- onBack: () => setScreen(SCREEN.SCOPE),
891
- });
892
-
893
- case SCREEN.PREVIEW:
894
- return h(PreviewScreen, {
895
- presetId,
896
- sounds,
897
- onAccept: (finalSounds) => {
898
- setSounds(finalSounds);
899
- setScreen(SCREEN.CONFIRM);
900
- },
901
- onBack: () => setScreen(SCREEN.PRESET),
902
- onUpdateSound: (eventId, path) => {
903
- setSounds((prev) => {
904
- const next = { ...prev };
905
- if (path === null) delete next[eventId];
906
- else next[eventId] = path;
907
- return next;
908
- });
909
- },
910
- });
911
-
912
- case SCREEN.GAME_PICK:
913
- return h(GamePickScreen, {
914
- onNext: (gameName, gamesList) => {
915
- const game = gamesList.find((g) => g.name === gameName);
916
- const catFiles = categorizeLooseFiles(game.files);
917
- setSelectedGame({ ...game, files: catFiles });
918
- setScreen(SCREEN.GAME_SOUNDS);
919
- },
920
- onExtract: (gameName, gamesList) => {
921
- const game = gamesList.find((g) => g.name === gameName);
922
- setSelectedGame(game);
923
- setScreen(SCREEN.EXTRACTING);
924
- },
925
- onBack: () => setScreen(SCREEN.PRESET),
926
- });
927
-
928
- case SCREEN.GAME_SOUNDS:
929
- return h(GameSoundsScreen, {
930
- game: selectedGame,
931
- onDone: (gameSounds) => {
932
- setSounds(gameSounds);
933
- setScreen(SCREEN.CONFIRM);
934
- },
935
- onBack: () => setScreen(SCREEN.GAME_PICK),
936
- });
937
-
938
- case SCREEN.EXTRACTING:
939
- return h(ExtractingScreen, {
940
- game: selectedGame,
941
- onDone: (result) => {
942
- if (result.error || result.files.length === 0) {
943
- // Go back to game pick — extraction failed
944
- setScreen(SCREEN.GAME_PICK);
945
- } else {
946
- // Update the selected game with extracted files and go to sound picker
947
- setSelectedGame({
948
- ...selectedGame,
949
- files: result.files,
950
- fileCount: result.files.length,
951
- hasAudio: true,
952
- });
953
- setScreen(SCREEN.GAME_SOUNDS);
954
- }
955
- },
956
- onBack: () => setScreen(SCREEN.GAME_PICK),
957
- });
958
-
959
- case SCREEN.CONFIRM:
960
- return h(ConfirmScreen, {
961
- scope,
962
- sounds,
963
- onConfirm: () => setScreen(SCREEN.INSTALLING),
964
- onBack: () => {
965
- if (selectedGame) setScreen(SCREEN.GAME_SOUNDS);
966
- else setScreen(SCREEN.PREVIEW);
967
- },
968
- });
969
-
970
- case SCREEN.INSTALLING:
971
- return h(InstallingScreen, {
972
- scope,
973
- sounds,
974
- onDone: (result) => {
975
- setInstallResult(result);
976
- setScreen(SCREEN.DONE);
977
- },
978
- });
979
-
980
- case SCREEN.DONE:
981
- return h(DoneScreen, { result: installResult });
982
-
983
- default:
984
- return h(Text, { color: "red" }, "Unknown screen");
985
- }
986
- })();
987
-
988
- return h(Box, { flexDirection: "column" },
989
- h(Header, null),
990
- content,
991
- );
992
- };
993
-
994
- // ── Entry ───────────────────────────────────────────────────────
995
- export async function run() {
996
- const AppComponent = isUninstallMode ? UninstallApp : InstallApp;
997
- const instance = render(h(AppComponent));
998
- await instance.waitUntilExit();
999
- }
1
+ import React, { useState, useEffect, useCallback, useRef } from "react";
2
+ import { render, Box, Text, useInput, useApp } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import { PRESETS, EVENTS } from "./presets.js";
5
+ import { playSoundWithCancel, getWavDuration } from "./player.js";
6
+ import { getAvailableGames } from "./scanner.js";
7
+ import { install, uninstall, getExistingSounds } from "./installer.js";
8
+ import { getVgmstreamPath, findPackedAudioFiles, extractToWav } from "./extractor.js";
9
+ import { extractUnityResource } from "./unity.js";
10
+ import { getCachedExtraction, cacheExtraction, categorizeLooseFiles, getCategories, sortFilesByPriority } from "./cache.js";
11
+ import { basename, dirname } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ const MAX_PLAY_SECONDS = 10;
16
+ const ACCENT = "#76C41E"; // Needle green-yellow midpoint
17
+
18
+ const h = React.createElement;
19
+
20
+ // ── Custom SelectInput components (bright colors for CMD) ────
21
+ const Indicator = ({ isSelected }) =>
22
+ h(Box, { marginRight: 1 }, isSelected
23
+ ? h(Text, { color: ACCENT }, "❯")
24
+ : h(Text, null, " "));
25
+
26
+ const Item = ({ isSelected, label }) =>
27
+ h(Text, { color: isSelected ? ACCENT : undefined, bold: isSelected }, label);
28
+
29
+ // ── Non-wrapping SelectInput (clamps at boundaries) ─────────────
30
+ const SelectInput = ({ items = [], isFocused = true, initialIndex = 0, indicatorComponent = Indicator, itemComponent = Item, limit: customLimit, onSelect, onHighlight }) => {
31
+ const hasLimit = typeof customLimit === "number" && items.length > customLimit;
32
+ const limit = hasLimit ? Math.min(customLimit, items.length) : items.length;
33
+ const [scrollOffset, setScrollOffset] = useState(0);
34
+ const [selectedIndex, setSelectedIndex] = useState(initialIndex ? Math.min(initialIndex, items.length - 1) : 0);
35
+ const previousItems = useRef(items);
36
+
37
+ useEffect(() => {
38
+ const prevValues = previousItems.current.map((i) => i.value);
39
+ const curValues = items.map((i) => i.value);
40
+ if (prevValues.length !== curValues.length || prevValues.some((v, i) => v !== curValues[i])) {
41
+ setScrollOffset(0);
42
+ setSelectedIndex(0);
43
+ }
44
+ previousItems.current = items;
45
+ }, [items]);
46
+
47
+ useInput(useCallback((input, key) => {
48
+ if (input === "k" || key.upArrow) {
49
+ if (selectedIndex <= 0) return; // clamp — don't wrap
50
+ const next = selectedIndex - 1;
51
+ let newOffset = scrollOffset;
52
+ if (hasLimit && next < scrollOffset) newOffset = next;
53
+ setSelectedIndex(next);
54
+ setScrollOffset(newOffset);
55
+ if (typeof onHighlight === "function") onHighlight(items[next]);
56
+ }
57
+ if (input === "j" || key.downArrow) {
58
+ if (selectedIndex >= items.length - 1) return; // clamp — don't wrap
59
+ const next = selectedIndex + 1;
60
+ let newOffset = scrollOffset;
61
+ if (hasLimit && next >= scrollOffset + limit) newOffset = next - limit + 1;
62
+ setSelectedIndex(next);
63
+ setScrollOffset(newOffset);
64
+ if (typeof onHighlight === "function") onHighlight(items[next]);
65
+ }
66
+ if (key.return) {
67
+ if (typeof onSelect === "function") onSelect(items[selectedIndex]);
68
+ }
69
+ }, [hasLimit, limit, scrollOffset, selectedIndex, items, onSelect, onHighlight]), { isActive: isFocused });
70
+
71
+ const visible = hasLimit ? items.slice(scrollOffset, scrollOffset + limit) : items;
72
+ return h(Box, { flexDirection: "column" }, visible.map((item, index) => {
73
+ const isSelected = index + scrollOffset === selectedIndex;
74
+ return h(Box, { key: item.key ?? item.value },
75
+ h(indicatorComponent, { isSelected }),
76
+ h(itemComponent, { ...item, isSelected }));
77
+ }));
78
+ };
79
+
80
+ // ── Screens ─────────────────────────────────────────────────────
81
+ const SCREEN = {
82
+ SCOPE: 0,
83
+ PRESET: 1,
84
+ PREVIEW: 2,
85
+ SCANNING: 3,
86
+ GAME_PICK: 4,
87
+ GAME_SOUNDS: 5,
88
+ EXTRACTING: 6,
89
+ CONFIRM: 7,
90
+ INSTALLING: 8,
91
+ DONE: 9,
92
+ };
93
+
94
+ const isUninstallMode = process.argv.includes("--uninstall") || process.argv.includes("--remove");
95
+
96
+ // ── Header component ────────────────────────────────────────────
97
+ const Header = () =>
98
+ h(Box, { flexDirection: "column", marginBottom: 1 },
99
+ h(Text, { bold: true, color: ACCENT }, " klaudio"),
100
+ h(Text, { dimColor: true }, isUninstallMode
101
+ ? " Remove sound effects from Claude Code"
102
+ : " Add sound effects to your Claude Code sessions"),
103
+ );
104
+
105
+ const NavHint = ({ back = true, extra = "" }) =>
106
+ h(Box, { marginTop: 1 },
107
+ h(Text, { dimColor: true },
108
+ (back ? " esc back" : "") +
109
+ (extra ? (back ? " • " : " ") + extra : "")
110
+ ),
111
+ );
112
+
113
+ // ── Screen: Scope ───────────────────────────────────────────────
114
+ const ScopeScreen = ({ onNext }) => {
115
+ const items = [
116
+ { label: "Global (~/.claude) — sounds in all projects", value: "global" },
117
+ { label: "This project (.claude/) — project-specific", value: "project" },
118
+ ];
119
+ return h(Box, { flexDirection: "column" },
120
+ h(Text, { bold: true }, " Where should sounds be installed?"),
121
+ h(Box, { marginLeft: 2 },
122
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, onSelect: (item) => onNext(item.value) }),
123
+ ),
124
+ );
125
+ };
126
+
127
+ // ── Screen: Preset ──────────────────────────────────────────────
128
+ const PresetScreen = ({ onNext, onBack }) => {
129
+ useInput((_, key) => { if (key.escape) onBack(); });
130
+
131
+ const items = [
132
+ ...Object.entries(PRESETS).map(([id, p]) => ({
133
+ label: `${p.icon} ${p.name} — ${p.description}`,
134
+ value: id,
135
+ })),
136
+ { label: "🕹️ Scan local games — find sounds from your Steam & Epic Games library", value: "_scan" },
137
+ { label: "📁 Custom files — provide your own sound files", value: "_custom" },
138
+ ];
139
+
140
+ return h(Box, { flexDirection: "column" },
141
+ h(Text, { bold: true }, " Choose a sound preset:"),
142
+ h(Box, { marginLeft: 2 },
143
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, onSelect: (item) => onNext(item.value) }),
144
+ ),
145
+ h(NavHint, { back: true }),
146
+ );
147
+ };
148
+
149
+ // ── Screen: Preview ─────────────────────────────────────────────
150
+ const PreviewScreen = ({ presetId, sounds, onAccept, onBack, onUpdateSound }) => {
151
+ const preset = PRESETS[presetId];
152
+ const eventIds = Object.keys(EVENTS);
153
+ const [currentEvent, setCurrentEvent] = useState(0);
154
+ const [playing, setPlaying] = useState(false);
155
+ const [elapsed, setElapsed] = useState(0);
156
+ const [durations, setDurations] = useState({});
157
+ const cancelRef = React.useRef(null);
158
+
159
+ const eventId = eventIds[currentEvent];
160
+ const eventInfo = EVENTS[eventId];
161
+ const soundFile = sounds[eventId];
162
+
163
+ const stopPlayback = useCallback(() => {
164
+ if (cancelRef.current) { cancelRef.current(); cancelRef.current = null; }
165
+ setPlaying(false);
166
+ setElapsed(0);
167
+ }, []);
168
+
169
+ // Fetch durations for all sound files
170
+ useEffect(() => {
171
+ for (const [eid, path] of Object.entries(sounds)) {
172
+ if (path && !durations[eid]) {
173
+ getWavDuration(path).then((dur) => {
174
+ if (dur != null) setDurations((d) => ({ ...d, [eid]: dur }));
175
+ });
176
+ }
177
+ }
178
+ }, [sounds]);
179
+
180
+ // Auto-play when current event changes (with debounce)
181
+ useEffect(() => {
182
+ if (!soundFile) return;
183
+ stopPlayback();
184
+ const timer = setTimeout(() => {
185
+ setPlaying(true);
186
+ const { promise, cancel } = playSoundWithCancel(soundFile);
187
+ cancelRef.current = cancel;
188
+ promise.catch(() => {}).finally(() => {
189
+ cancelRef.current = null;
190
+ setPlaying(false);
191
+ setElapsed(0);
192
+ });
193
+ }, 150);
194
+ return () => {
195
+ clearTimeout(timer);
196
+ if (cancelRef.current) {
197
+ cancelRef.current();
198
+ cancelRef.current = null;
199
+ }
200
+ };
201
+ }, [currentEvent]);
202
+
203
+ useInput((_, key) => {
204
+ if (key.escape) {
205
+ if (playing) {
206
+ stopPlayback();
207
+ } else {
208
+ onBack();
209
+ }
210
+ } else if (key.leftArrow || key.upArrow) {
211
+ if (currentEvent > 0) {
212
+ stopPlayback();
213
+ setCurrentEvent((i) => i - 1);
214
+ }
215
+ } else if (key.rightArrow || key.downArrow) {
216
+ if (currentEvent < eventIds.length - 1) {
217
+ stopPlayback();
218
+ setCurrentEvent((i) => i + 1);
219
+ }
220
+ } else if (key.return) {
221
+ stopPlayback();
222
+ onAccept(sounds);
223
+ }
224
+ });
225
+
226
+ // Elapsed timer while playing
227
+ useEffect(() => {
228
+ if (!playing) return;
229
+ setElapsed(0);
230
+ const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
231
+ return () => clearInterval(interval);
232
+ }, [playing]);
233
+
234
+ const stepLabel = `(${currentEvent + 1}/${eventIds.length})`;
235
+ const dur = durations[eventId];
236
+ const durLabel = dur != null ? ` (${dur > MAX_PLAY_SECONDS ? MAX_PLAY_SECONDS : dur}s)` : "";
237
+
238
+ return h(Box, { flexDirection: "column" },
239
+ h(Text, { bold: true }, ` ${preset.icon} ${preset.name}`),
240
+ h(Text, { dimColor: true }, ` ${preset.description}`),
241
+ h(Box, { marginTop: 1, flexDirection: "column" },
242
+ ...eventIds.map((eid, i) => {
243
+ const d = durations[eid];
244
+ const dStr = d != null ? ` (${d > MAX_PLAY_SECONDS ? MAX_PLAY_SECONDS : d}s)` : "";
245
+ return h(Text, { key: eid, marginLeft: 2,
246
+ color: i === currentEvent ? "#00FFFF" : i < currentEvent ? "green" : "white",
247
+ bold: i === currentEvent,
248
+ },
249
+ i < currentEvent ? " ✓ " : i === currentEvent ? " ▸ " : " ",
250
+ `${EVENTS[eid].name}: `,
251
+ sounds[eid] ? `${basename(sounds[eid])}${dStr}` : "(skipped)",
252
+ );
253
+ }),
254
+ ),
255
+ h(Box, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: playing ? "green" : "cyan", paddingX: 2, paddingY: 0, marginLeft: 2, marginRight: 2 },
256
+ h(Text, { bold: true, color: playing ? "green" : "cyan" },
257
+ `${eventInfo.name} ${stepLabel}`,
258
+ ),
259
+ h(Text, { dimColor: true },
260
+ soundFile
261
+ ? `Sound: ${basename(soundFile)}${durLabel}`
262
+ : "No sound file selected",
263
+ ),
264
+ h(Text, { dimColor: true },
265
+ `Triggers: ${eventInfo.description}`,
266
+ ),
267
+ playing
268
+ ? h(Box, { marginTop: 1 },
269
+ h(Text, { color: "green", bold: true }, h(Spinner, { type: "dots" })),
270
+ h(Text, { color: "green", bold: true }, ` Now playing: ${basename(soundFile)} ${elapsed}s / ${MAX_PLAY_SECONDS}s max`),
271
+ )
272
+ : null,
273
+ ),
274
+ h(NavHint, { back: true, extra: "↑↓ switch events • enter accept all" }),
275
+ );
276
+ };
277
+
278
+ // ── Screen: Game Pick (with progressive background scanning) ────
279
+ const GamePickScreen = ({ onNext, onExtract, onBack }) => {
280
+ const [games, setGames] = useState([]);
281
+ const [scanning, setScanning] = useState(true);
282
+ const [scanStatus, setScanStatus] = useState("Discovering game directories...");
283
+ const [filter, setFilter] = useState("");
284
+
285
+ // Start scanning on mount, add games progressively
286
+ useEffect(() => {
287
+ let cancelled = false;
288
+ getAvailableGames(
289
+ (progress) => {
290
+ if (cancelled) return;
291
+ if (progress.phase === "dirs") {
292
+ setScanStatus(`Scanning ${progress.dirs.length} directories...`);
293
+ } else if (progress.phase === "scanning") {
294
+ setScanStatus(`Scanning: ${progress.game}`);
295
+ }
296
+ },
297
+ (game) => {
298
+ if (cancelled) return;
299
+ // Add each game as it's found (only if it has audio or can extract)
300
+ setGames((prev) => {
301
+ if (prev.some((g) => g.name === game.name)) return prev;
302
+ const next = [...prev, game];
303
+ // Sort: playable first, then extractable, then others
304
+ next.sort((a, b) => {
305
+ if (a.hasAudio !== b.hasAudio) return a.hasAudio ? -1 : 1;
306
+ if (a.canExtract !== b.canExtract) return a.canExtract ? -1 : 1;
307
+ return a.name.localeCompare(b.name);
308
+ });
309
+ return next;
310
+ });
311
+ },
312
+ ).then(() => {
313
+ if (!cancelled) setScanning(false);
314
+ }).catch(() => {
315
+ if (!cancelled) setScanning(false);
316
+ });
317
+ return () => { cancelled = true; };
318
+ }, []);
319
+
320
+ useInput((input, key) => {
321
+ if (key.escape) {
322
+ if (filter) setFilter("");
323
+ else onBack();
324
+ } else if (key.backspace || key.delete) {
325
+ setFilter((f) => f.slice(0, -1));
326
+ } else if (input && !key.ctrl && !key.meta && input.length === 1 && input.charCodeAt(0) >= 32) {
327
+ setFilter((f) => f + input);
328
+ }
329
+ });
330
+
331
+ const usableGames = games.filter((g) => g.hasAudio || g.canExtract);
332
+ const noAudio = games.filter((g) => !g.hasAudio && !g.canExtract);
333
+
334
+ const allItems = usableGames
335
+ .sort((a, b) => a.name.localeCompare(b.name))
336
+ .map((g) => {
337
+ if (g.hasAudio) {
338
+ return {
339
+ key: `play:${g.name}`,
340
+ label: `${g.name} (${g.fileCount} audio files)`,
341
+ value: `play:${g.name}`,
342
+ };
343
+ }
344
+ const hasUnity = (g.unityAudioCount || 0) > 0;
345
+ const hasPacked = (g.packedAudioCount || 0) > 0;
346
+ const detail = hasUnity && !hasPacked
347
+ ? `${g.unityAudioCount} Unity audio resource(s) extract`
348
+ : hasPacked && !hasUnity
349
+ ? `${g.packedAudioCount} packed — extract with vgmstream`
350
+ : `${g.packedAudioCount} packed + ${g.unityAudioCount} Unity extract`;
351
+ return {
352
+ key: `extract:${g.name}`,
353
+ label: `${g.name} (${detail})`,
354
+ value: `extract:${g.name}`,
355
+ };
356
+ });
357
+
358
+ const filterLower = filter.toLowerCase();
359
+ const items = filter
360
+ ? allItems.filter((i) => i.label.toLowerCase().includes(filterLower))
361
+ : allItems;
362
+
363
+ return h(Box, { flexDirection: "column" },
364
+ scanning
365
+ ? h(Box, { marginLeft: 2 },
366
+ h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
367
+ h(Text, null, ` ${scanStatus}`),
368
+ games.length > 0
369
+ ? h(Text, { color: "green" }, ` (${games.length} found)`)
370
+ : null,
371
+ )
372
+ : h(Text, { bold: true, marginLeft: 2 },
373
+ ` Found ${games.length} game(s):`,
374
+ ),
375
+ filter
376
+ ? h(Box, { marginLeft: 4 },
377
+ h(Text, { color: "yellow" }, "Filter: "),
378
+ h(Text, { bold: true }, filter),
379
+ h(Text, { dimColor: true }, ` (${items.length} match${items.length !== 1 ? "es" : ""})`),
380
+ )
381
+ : items.length > 0
382
+ ? h(Text, { dimColor: true, marginLeft: 4 }, "Type to filter... (select a game while scan continues)")
383
+ : null,
384
+ items.length > 0
385
+ ? h(Box, { marginLeft: 2 },
386
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item,
387
+ items,
388
+ limit: 15,
389
+ onSelect: (item) => {
390
+ const [type, ...rest] = item.value.split(":");
391
+ const name = rest.join(":");
392
+ if (type === "extract") onExtract(name, games);
393
+ else onNext(name, games);
394
+ },
395
+ }),
396
+ )
397
+ : !scanning
398
+ ? h(Text, { color: "yellow", marginLeft: 4 }, "No games with usable audio found.")
399
+ : null,
400
+ noAudio.length > 0 && !filter && !scanning
401
+ ? h(Box, { flexDirection: "column", marginTop: 1, marginLeft: 4 },
402
+ h(Text, { dimColor: true },
403
+ `${noAudio.length} game(s) with no extractable audio:`,
404
+ ),
405
+ h(Text, { dimColor: true },
406
+ noAudio.map((g) => g.name).join(", "),
407
+ ),
408
+ )
409
+ : null,
410
+ h(NavHint, { back: true }),
411
+ );
412
+ };
413
+
414
+ // ── Screen: Game Sound Picker ───────────────────────────────────
415
+ // Three-phase: category pick → file pick → preview/accept/repick
416
+ const CATEGORY_LABELS = {
417
+ all: "All sounds", ambient: "Ambient", music: "Music", sfx: "SFX",
418
+ ui: "UI", voice: "Voice / Dialogue", creature: "Creatures / Animals", other: "Other",
419
+ };
420
+ const CATEGORY_ICONS = {
421
+ voice: "🗣", creature: "🐾", ui: "🖱", sfx: "💥",
422
+ ambient: "🌿", music: "🎵", other: "📦", all: "📂",
423
+ };
424
+
425
+ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
426
+ const eventIds = Object.keys(EVENTS);
427
+ const [currentEvent, setCurrentEvent] = useState(0);
428
+ const [playing, setPlaying] = useState(false);
429
+ const [elapsed, setElapsed] = useState(0);
430
+ const [highlightedFile, setHighlightedFile] = useState(null);
431
+ const [fileDurations, setFileDurations] = useState({});
432
+ const [filter, setFilter] = useState("");
433
+ const [activeCategory, setActiveCategory] = useState(null); // null = show category picker
434
+ const [autoPreview, setAutoPreview] = useState(true);
435
+ const [justSelected, setJustSelected] = useState(null); // brief confirmation flash
436
+ const cancelRef = React.useRef(null);
437
+
438
+ // Determine available categories with counts
439
+ const hasCategories = game.files.some((f) => f.category);
440
+ const { categories, counts } = hasCategories
441
+ ? getCategories(game.files)
442
+ : { categories: ["all"], counts: {} };
443
+ const meaningfulCats = categories.filter((c) => c !== "all" && (counts[c] || 0) >= 2);
444
+ const showCategoryPicker = meaningfulCats.length >= 2;
445
+
446
+ // Sort files: voice first, then by priority
447
+ const sortedFiles = hasCategories ? sortFilesByPriority(game.files) : game.files;
448
+
449
+ // Fetch durations for visible files
450
+ useEffect(() => {
451
+ for (const f of sortedFiles.slice(0, 50)) {
452
+ if (!fileDurations[f.path]) {
453
+ getWavDuration(f.path).then((dur) => {
454
+ if (dur != null) setFileDurations((d) => ({ ...d, [f.path]: dur }));
455
+ });
456
+ }
457
+ }
458
+ }, [game.files]);
459
+
460
+ // Filter files by category
461
+ const categoryFiles = activeCategory && activeCategory !== "all"
462
+ ? sortedFiles.filter((f) => f.category === activeCategory).slice(0, 50)
463
+ : sortedFiles.slice(0, 50);
464
+
465
+ // Stop current playback helper
466
+ const stopPlayback = useCallback(() => {
467
+ if (cancelRef.current) {
468
+ cancelRef.current();
469
+ cancelRef.current = null;
470
+ }
471
+ setPlaying(false);
472
+ setElapsed(0);
473
+ }, []);
474
+
475
+ // Auto-preview: play sound when highlighted file changes (with debounce)
476
+ useEffect(() => {
477
+ if (!autoPreview || !highlightedFile || highlightedFile === "_skip") {
478
+ return;
479
+ }
480
+ // Cancel previous playback immediately
481
+ if (cancelRef.current) {
482
+ cancelRef.current();
483
+ cancelRef.current = null;
484
+ }
485
+ setPlaying(false);
486
+ setElapsed(0);
487
+ // Debounce: wait 150ms before starting playback so scrubbing doesn't spam
488
+ const timer = setTimeout(() => {
489
+ setPlaying(true);
490
+ setElapsed(0);
491
+ const { promise, cancel } = playSoundWithCancel(highlightedFile);
492
+ cancelRef.current = cancel;
493
+ promise.catch(() => {}).finally(() => {
494
+ cancelRef.current = null;
495
+ setPlaying(false);
496
+ setElapsed(0);
497
+ });
498
+ }, 150);
499
+ return () => {
500
+ clearTimeout(timer);
501
+ if (cancelRef.current) {
502
+ cancelRef.current();
503
+ cancelRef.current = null;
504
+ }
505
+ };
506
+ }, [highlightedFile, autoPreview]);
507
+
508
+ useInput((input, key) => {
509
+ if (key.tab) {
510
+ // Tab cycles through events + Apply tab (if any sounds assigned)
511
+ stopPlayback();
512
+ const hasSounds = Object.values(sounds).some(Boolean);
513
+ const tabCount = hasSounds ? eventIds.length + 1 : eventIds.length;
514
+ setCurrentEvent((i) => (i + 1) % tabCount);
515
+ setHighlightedFile(null);
516
+ setActiveCategory(null);
517
+ setFilter("");
518
+ } else if (key.escape) {
519
+ if (playing) {
520
+ stopPlayback();
521
+ } else if (filter) {
522
+ setFilter("");
523
+ } else if (activeCategory !== null && showCategoryPicker) {
524
+ stopPlayback();
525
+ setActiveCategory(null);
526
+ } else {
527
+ stopPlayback();
528
+ onBack();
529
+ }
530
+ } else if (input === "p" && !key.ctrl && !key.meta) {
531
+ // Toggle auto-preview
532
+ setAutoPreview((prev) => {
533
+ if (prev) stopPlayback();
534
+ return !prev;
535
+ });
536
+ } else if (activeCategory !== null) {
537
+ if (key.backspace || key.delete) {
538
+ setFilter((f) => f.slice(0, -1));
539
+ } else if (input && input !== "p" && !key.ctrl && !key.meta && input.length === 1 && input.charCodeAt(0) >= 32) {
540
+ setFilter((f) => f + input);
541
+ }
542
+ }
543
+ });
544
+
545
+ // Elapsed timer while playing
546
+ useEffect(() => {
547
+ if (!playing) return;
548
+ setElapsed(0);
549
+ const interval = setInterval(() => {
550
+ setElapsed((e) => e + 1);
551
+ }, 1000);
552
+ return () => clearInterval(interval);
553
+ }, [playing]);
554
+
555
+ const eventId = eventIds[currentEvent];
556
+ const eventInfo = EVENTS[eventId];
557
+ const stepLabel = `(${currentEvent + 1}/${eventIds.length})`;
558
+
559
+ const advance = useCallback((selectedFile, selectedEventId) => {
560
+ stopPlayback();
561
+ // Show confirmation flash
562
+ if (selectedFile) {
563
+ setJustSelected({ file: basename(selectedFile), event: EVENTS[selectedEventId]?.name || selectedEventId });
564
+ }
565
+ const doAdvance = () => {
566
+ setJustSelected(null);
567
+ // Move to next event that hasn't been assigned yet, or wrap around
568
+ const nextUnassigned = eventIds.findIndex((eid, i) => i > currentEvent && !sounds[eid]);
569
+ if (nextUnassigned >= 0) {
570
+ setCurrentEvent(nextUnassigned);
571
+ } else {
572
+ // All done or wrapped go to next sequential
573
+ setCurrentEvent((i) => Math.min(i + 1, eventIds.length - 1));
574
+ }
575
+ setHighlightedFile(null);
576
+ setActiveCategory(null);
577
+ setFilter("");
578
+ };
579
+ if (selectedFile) {
580
+ setTimeout(doAdvance, 600);
581
+ } else {
582
+ doAdvance();
583
+ }
584
+ }, [currentEvent, eventIds, sounds, stopPlayback]);
585
+
586
+ const nowPlayingFile = playing && highlightedFile && highlightedFile !== "_skip"
587
+ ? highlightedFile : null;
588
+
589
+ const hasAnySounds = Object.values(sounds).some(Boolean);
590
+ const allAssigned = eventIds.every((eid) => sounds[eid]);
591
+ // currentEvent can be eventIds.length to mean "Done" tab
592
+ const onDoneTab = currentEvent >= eventIds.length;
593
+
594
+ const headerBox = h(Box, { marginLeft: 2, marginBottom: 1, flexDirection: "column", borderStyle: "round", borderColor: nowPlayingFile ? "green" : ACCENT, paddingX: 2 },
595
+ h(Text, { bold: true, color: nowPlayingFile ? "green" : ACCENT },
596
+ `${game.name}`,
597
+ ),
598
+ h(Box, { marginTop: 0, gap: 2 },
599
+ ...eventIds.map((eid, i) => {
600
+ const assigned = sounds[eid];
601
+ const isCurrent = i === currentEvent;
602
+ const truncName = assigned ? basename(assigned).slice(0, 20) : null;
603
+ const prefix = isCurrent ? "▸" : assigned ? "✓" : "·";
604
+ const label = truncName
605
+ ? `${prefix} ${EVENTS[eid].name}: ${truncName}`
606
+ : `${prefix} ${EVENTS[eid].name}`;
607
+ return h(Text, {
608
+ key: eid,
609
+ bold: isCurrent,
610
+ color: isCurrent ? ACCENT : assigned ? "green" : "gray",
611
+ }, label);
612
+ }),
613
+ h(Text, {
614
+ key: "_done",
615
+ bold: onDoneTab,
616
+ color: onDoneTab ? ACCENT : hasAnySounds ? "green" : "gray",
617
+ }, onDoneTab ? "▸ ✓ Apply" : hasAnySounds ? "· ✓ Apply" : "· Apply"),
618
+ h(Text, { dimColor: true }, "(tab)"),
619
+ ),
620
+ onDoneTab
621
+ ? h(Text, { dimColor: true }, "Press enter to apply your sound selections")
622
+ : sounds[eventId]
623
+ ? h(Text, { color: "green" }, `✓ ${basename(sounds[eventId])} — ${eventInfo.description}`)
624
+ : h(Text, { dimColor: true }, `${eventInfo.description}`),
625
+ );
626
+
627
+ const nowPlayingBar = h(Box, { marginLeft: 2, height: 1 },
628
+ justSelected
629
+ ? h(Text, { color: "green", bold: true }, ` Selected "${justSelected.file}" for ${justSelected.event}`)
630
+ : nowPlayingFile
631
+ ? h(Box, null,
632
+ h(Text, { color: "green", bold: true }, h(Spinner, { type: "dots" })),
633
+ h(Text, { color: "green", bold: true }, ` Now playing: ${basename(nowPlayingFile)} ${elapsed}s / ${MAX_PLAY_SECONDS}s max`),
634
+ )
635
+ : h(Text, { dimColor: true }, " "),
636
+ );
637
+
638
+ // Apply tab: show summary and confirm
639
+ if (onDoneTab) {
640
+ const confirmItems = [
641
+ { label: "✓ Apply sounds", value: "apply" },
642
+ { label: "← Back to editing", value: "back" },
643
+ ];
644
+ return h(Box, { flexDirection: "column" },
645
+ headerBox,
646
+ h(Box, { flexDirection: "column", marginLeft: 4, marginBottom: 1 },
647
+ ...eventIds.map((eid) =>
648
+ h(Text, { key: eid, color: sounds[eid] ? "green" : "gray" },
649
+ sounds[eid] ? ` ✓ ${EVENTS[eid].name}: ${basename(sounds[eid])}` : ` · ${EVENTS[eid].name}: (skipped)`,
650
+ ),
651
+ ),
652
+ ),
653
+ h(Box, { marginLeft: 2 },
654
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item,
655
+ items: confirmItems,
656
+ onSelect: (item) => {
657
+ if (item.value === "apply") {
658
+ stopPlayback();
659
+ onDone();
660
+ } else {
661
+ setCurrentEvent(0);
662
+ }
663
+ },
664
+ }),
665
+ ),
666
+ nowPlayingBar,
667
+ h(NavHint, { back: true }),
668
+ );
669
+ }
670
+
671
+ // Phase 0: Category picker
672
+ if (activeCategory === null && showCategoryPicker) {
673
+ const catItems = [
674
+ ...meaningfulCats.map((cat) => ({
675
+ label: `${CATEGORY_ICONS[cat] || "📁"} ${CATEGORY_LABELS[cat] || cat} (${counts[cat]} sounds)`,
676
+ value: cat,
677
+ })),
678
+ { label: `${CATEGORY_ICONS.all} All sounds (${game.files.length})`, value: "all" },
679
+ { label: "(skip this event)", value: "_skip" },
680
+ ];
681
+
682
+ return h(Box, { flexDirection: "column" },
683
+ headerBox,
684
+ h(Text, { bold: true, marginLeft: 4 }, "Pick a category:"),
685
+ h(Box, { marginLeft: 2 },
686
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item,
687
+ items: catItems,
688
+ onSelect: (item) => {
689
+ if (item.value === "_skip") {
690
+ advance();
691
+ } else {
692
+ setActiveCategory(item.value);
693
+ }
694
+ },
695
+ }),
696
+ ),
697
+ nowPlayingBar,
698
+ h(NavHint, { back: true }),
699
+ );
700
+ }
701
+
702
+ // Phase 1: Browse and pick files (auto-preview plays on highlight)
703
+ const filterLower = filter.toLowerCase();
704
+ const allFileItems = categoryFiles.map((f) => {
705
+ const dur = fileDurations[f.path];
706
+ const durStr = dur != null ? ` (${dur > MAX_PLAY_SECONDS ? MAX_PLAY_SECONDS + "s max" : dur + "s"})` : "";
707
+ const catTag = (!activeCategory || activeCategory === "all") && f.category && f.category !== "other"
708
+ ? `[${(CATEGORY_LABELS[f.category] || f.category).toUpperCase()}] ` : "";
709
+ const name = f.displayName || f.name;
710
+ return {
711
+ label: `${catTag}${name}${durStr}`,
712
+ value: f.path,
713
+ };
714
+ });
715
+
716
+ const filteredFiles = filter
717
+ ? allFileItems.filter((i) => i.label.toLowerCase().includes(filterLower))
718
+ : allFileItems;
719
+
720
+ const fileItems = [
721
+ ...filteredFiles,
722
+ ...(!filter ? [{ label: "(skip this event)", value: "_skip" }] : []),
723
+ ];
724
+
725
+ const catLabel = activeCategory && activeCategory !== "all"
726
+ ? `${CATEGORY_ICONS[activeCategory] || ""} ${CATEGORY_LABELS[activeCategory] || activeCategory}`
727
+ : null;
728
+
729
+ return h(Box, { flexDirection: "column" },
730
+ headerBox,
731
+ catLabel
732
+ ? h(Text, { bold: true, color: ACCENT, marginLeft: 4 }, catLabel)
733
+ : null,
734
+ h(Box, { marginLeft: 4 },
735
+ h(Text, { color: autoPreview ? "green" : "gray", bold: autoPreview },
736
+ autoPreview ? "♫ Auto-preview ON" : "♫ Auto-preview OFF"
737
+ ),
738
+ h(Text, { dimColor: true }, " (p to toggle)"),
739
+ ),
740
+ filter
741
+ ? h(Box, { marginLeft: 4 },
742
+ h(Text, { color: "yellow" }, "Filter: "),
743
+ h(Text, { bold: true }, filter),
744
+ h(Text, { dimColor: true }, ` (${filteredFiles.length} match${filteredFiles.length !== 1 ? "es" : ""})`),
745
+ )
746
+ : categoryFiles.length > 15
747
+ ? h(Text, { dimColor: true, marginLeft: 4 }, "Type to filter...")
748
+ : null,
749
+ fileItems.length > 0
750
+ ? h(Box, { marginLeft: 2 },
751
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item,
752
+ items: fileItems,
753
+ limit: 15,
754
+ onHighlight: (item) => {
755
+ setHighlightedFile(item.value);
756
+ },
757
+ onSelect: (item) => {
758
+ stopPlayback();
759
+ if (item.value === "_skip") {
760
+ advance(null, null);
761
+ } else {
762
+ onSelectSound(eventId, item.value);
763
+ advance(item.value, eventId);
764
+ }
765
+ },
766
+ }),
767
+ )
768
+ : h(Text, { color: "yellow", marginLeft: 4 }, "No matches."),
769
+ nowPlayingBar,
770
+ h(NavHint, { back: true, extra: "tab switch event" }),
771
+ );
772
+ };
773
+
774
+ // ── Screen: Extracting ──────────────────────────────────────────
775
+ const ExtractingScreen = ({ game, onDone, onBack }) => {
776
+ const [status, setStatus] = useState("Checking cache...");
777
+ const [extracted, setExtracted] = useState(0);
778
+
779
+ useInput((_, key) => { if (key.escape) onBack(); });
780
+
781
+ useEffect(() => {
782
+ let cancelled = false;
783
+
784
+ (async () => {
785
+ try {
786
+ // Check cache first
787
+ const cached = await getCachedExtraction(game.name);
788
+ if (cached && cached.files.length > 0) {
789
+ setStatus(`Found ${cached.files.length} cached sounds`);
790
+ if (!cancelled) {
791
+ onDone({
792
+ files: cached.files.map((f) => ({
793
+ path: f.path,
794
+ name: f.name,
795
+ displayName: f.displayName,
796
+ category: f.category,
797
+ dir: dirname(f.path),
798
+ })),
799
+ categories: cached.categories,
800
+ fromCache: true,
801
+ });
802
+ }
803
+ return;
804
+ }
805
+
806
+ const outputDir = join(tmpdir(), "klonk-extract", game.name.replace(/[^a-zA-Z0-9]/g, "_"));
807
+ const allOutputs = [];
808
+
809
+ // Unity .resource files extract FSB5 banks directly (no vgmstream needed for PCM16)
810
+ const fsbFiles = []; // Vorbis .fsb files that need vgmstream conversion
811
+ if (game.unityResources && game.unityResources.length > 0) {
812
+ setStatus(`Extracting Unity audio from ${game.unityResources.length} resource file(s)...`);
813
+ for (const resPath of game.unityResources) {
814
+ if (cancelled) return;
815
+ setStatus(`Extracting: ${basename(resPath)}`);
816
+ try {
817
+ const extracted = await extractUnityResource(resPath, outputDir);
818
+ for (const f of extracted) {
819
+ if (f.path.endsWith(".wav")) {
820
+ allOutputs.push(f.path);
821
+ } else if (f.path.endsWith(".fsb")) {
822
+ fsbFiles.push({ path: f.path, name: f.name });
823
+ }
824
+ }
825
+ setExtracted(allOutputs.length);
826
+ } catch { /* skip */ }
827
+ }
828
+ }
829
+
830
+ // Convert extracted .fsb Vorbis files via vgmstream, or handle traditional packed audio
831
+ const needsVgmstream = fsbFiles.length > 0 || (allOutputs.length === 0 && !game.unityResources?.length);
832
+ if (needsVgmstream) {
833
+ // Get vgmstream-cli (downloads if needed)
834
+ setStatus("Getting vgmstream-cli...");
835
+ const vgmstream = await getVgmstreamPath((msg) => {
836
+ if (!cancelled) setStatus(msg);
837
+ });
838
+
839
+ // Convert Unity-extracted .fsb files
840
+ for (const fsb of fsbFiles) {
841
+ if (cancelled) return;
842
+ setStatus(`Converting: ${fsb.name}`);
843
+ try {
844
+ const outputs = await extractToWav(fsb.path, outputDir, vgmstream);
845
+ allOutputs.push(...outputs);
846
+ setExtracted(allOutputs.length);
847
+ } catch { /* skip */ }
848
+ }
849
+
850
+ // Also scan for traditional packed audio (Wwise/FMOD)
851
+ if (allOutputs.length === 0 || !game.unityResources?.length) {
852
+ setStatus(`Scanning ${game.name} for packed audio...`);
853
+ const packedFiles = await findPackedAudioFiles(game.path, 30);
854
+
855
+ if (packedFiles.length === 0 && allOutputs.length === 0) {
856
+ if (!cancelled) onDone({ files: [], error: "No extractable audio files found" });
857
+ return;
858
+ }
859
+
860
+ setStatus(`Found ${packedFiles.length} files. Extracting...`);
861
+
862
+ for (const file of packedFiles) {
863
+ if (cancelled) return;
864
+ setStatus(`Extracting: ${file.name}`);
865
+ try {
866
+ const outputs = await extractToWav(file.path, outputDir, vgmstream);
867
+ allOutputs.push(...outputs);
868
+ setExtracted(allOutputs.length);
869
+ } catch {
870
+ // Skip files that fail
871
+ }
872
+ }
873
+ }
874
+ }
875
+
876
+ if (!cancelled) {
877
+ // Cache the results with category metadata
878
+ const rawFiles = allOutputs.map((p) => ({ path: p, name: basename(p) }));
879
+ setStatus("Caching extracted sounds...");
880
+ const manifest = await cacheExtraction(game.name, rawFiles, game.path);
881
+
882
+ onDone({
883
+ files: manifest.files.map((f) => ({
884
+ path: f.path,
885
+ name: f.name,
886
+ displayName: f.displayName,
887
+ category: f.category,
888
+ dir: dirname(f.path),
889
+ })),
890
+ categories: manifest.categories,
891
+ });
892
+ }
893
+ } catch (err) {
894
+ if (!cancelled) onDone({ files: [], error: err.message });
895
+ }
896
+ })();
897
+
898
+ return () => { cancelled = true; };
899
+ }, []);
900
+
901
+ return h(Box, { flexDirection: "column" },
902
+ h(Box, { marginLeft: 2, flexDirection: "column", borderStyle: "round", borderColor: ACCENT, paddingX: 2 },
903
+ h(Text, { bold: true, color: ACCENT }, `Extracting audio from ${game.name}`),
904
+ h(Box, { marginTop: 1 },
905
+ h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
906
+ h(Text, null, ` ${status}`),
907
+ ),
908
+ extracted > 0
909
+ ? h(Text, { color: "green" }, ` ${extracted} sound(s) extracted so far`)
910
+ : null,
911
+ ),
912
+ h(NavHint, { back: true }),
913
+ );
914
+ };
915
+
916
+ // ── Screen: Confirm ─────────────────────────────────────────────
917
+ const ConfirmScreen = ({ scope, sounds, onConfirm, onBack }) => {
918
+ useInput((_, key) => { if (key.escape) onBack(); });
919
+
920
+ const items = [
921
+ { label: "✓ Yes, install!", value: "yes" },
922
+ { label: "✗ No, go back", value: "no" },
923
+ ];
924
+
925
+ const soundEntries = Object.entries(sounds).filter(([_, path]) => path);
926
+
927
+ return h(Box, { flexDirection: "column" },
928
+ h(Text, { bold: true, marginLeft: 2 }, " Ready to install:"),
929
+ h(Box, { marginTop: 1, flexDirection: "column" },
930
+ h(Text, { marginLeft: 4 }, `Scope: ${scope === "global" ? "Global (~/.claude)" : "This project (.claude/)"}`),
931
+ ...soundEntries.map(([eid, path]) =>
932
+ h(Text, { key: eid, marginLeft: 4 },
933
+ `${EVENTS[eid].name} → ${basename(path)}`
934
+ )
935
+ ),
936
+ ),
937
+ h(Box, { marginTop: 1, marginLeft: 2 },
938
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, onSelect: (item) => {
939
+ if (item.value === "yes") onConfirm();
940
+ else onBack();
941
+ }}),
942
+ ),
943
+ h(NavHint, { back: true }),
944
+ );
945
+ };
946
+
947
+ // ── Screen: Installing ──────────────────────────────────────────
948
+ const InstallingScreen = ({ scope, sounds, onDone }) => {
949
+ useEffect(() => {
950
+ const validSounds = {};
951
+ for (const [eventId, path] of Object.entries(sounds)) {
952
+ if (path) validSounds[eventId] = path;
953
+ }
954
+ install({ scope, sounds: validSounds }).then(onDone).catch((err) => {
955
+ onDone({ error: err.message });
956
+ });
957
+ }, []);
958
+
959
+ return h(Box, { marginLeft: 2 },
960
+ h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
961
+ h(Text, null, " Installing sounds..."),
962
+ );
963
+ };
964
+
965
+ // ── Screen: Done ────────────────────────────────────────────────
966
+ const DoneScreen = ({ result }) => {
967
+ const { exit } = useApp();
968
+
969
+ useEffect(() => {
970
+ // Play the "stop" sound as a demo if it was installed
971
+ if (result.installedSounds?.stop) {
972
+ playSoundWithCancel(result.installedSounds.stop).promise.catch(() => {});
973
+ }
974
+ const timer = setTimeout(() => exit(), 1500);
975
+ return () => clearTimeout(timer);
976
+ }, []);
977
+
978
+ if (result.error) {
979
+ return h(Box, { flexDirection: "column", marginLeft: 2 },
980
+ h(Text, { color: "red", bold: true }, " ✗ Installation failed:"),
981
+ h(Text, { color: "red" }, ` ${result.error}`),
982
+ );
983
+ }
984
+
985
+ return h(Box, { flexDirection: "column", marginLeft: 2 },
986
+ h(Text, { color: "green", bold: true }, " ✓ Sounds installed!"),
987
+ h(Box, { marginTop: 1, flexDirection: "column" },
988
+ h(Text, null, ` Sound files: ${result.soundsDir}`),
989
+ h(Text, null, ` Config: ${result.settingsFile}`),
990
+ ),
991
+ h(Box, { marginTop: 1, flexDirection: "column" },
992
+ h(Text, null, " Your Claude Code sessions will now play sounds for:"),
993
+ ...Object.keys(result.installedSounds).map((eventId) =>
994
+ h(Text, { key: eventId, color: "green" }, ` • ${EVENTS[eventId].name}`)
995
+ ),
996
+ ),
997
+ h(Box, { marginTop: 1 },
998
+ h(Text, { dimColor: true }, " To remove: npx klonk --uninstall"),
999
+ ),
1000
+ );
1001
+ };
1002
+
1003
+ // ── Uninstall App ───────────────────────────────────────────────
1004
+ const UninstallApp = () => {
1005
+ const { exit } = useApp();
1006
+ const [phase, setPhase] = useState("scope"); // scope | working | done | notfound
1007
+ const [scope, setScope] = useState(null);
1008
+
1009
+ useEffect(() => {
1010
+ if (phase === "working" && scope) {
1011
+ uninstall(scope).then((ok) => {
1012
+ setPhase(ok ? "done" : "notfound");
1013
+ setTimeout(() => exit(), 500);
1014
+ });
1015
+ }
1016
+ }, [phase, scope]);
1017
+
1018
+ if (phase === "scope") {
1019
+ return h(Box, { flexDirection: "column" },
1020
+ h(Header, null),
1021
+ h(ScopeScreen, {
1022
+ onNext: (s) => { setScope(s); setPhase("working"); },
1023
+ }),
1024
+ );
1025
+ }
1026
+
1027
+ if (phase === "working") {
1028
+ return h(Box, { flexDirection: "column" },
1029
+ h(Header, null),
1030
+ h(Box, { marginLeft: 2 },
1031
+ h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1032
+ h(Text, null, " Removing sounds..."),
1033
+ ),
1034
+ );
1035
+ }
1036
+
1037
+ if (phase === "done") {
1038
+ return h(Box, { flexDirection: "column" },
1039
+ h(Header, null),
1040
+ h(Text, { color: "green", marginLeft: 2 }, " ✓ Klonk hooks removed."),
1041
+ );
1042
+ }
1043
+
1044
+ return h(Box, { flexDirection: "column" },
1045
+ h(Header, null),
1046
+ h(Text, { color: "yellow", marginLeft: 2 }, " No Klonk configuration found."),
1047
+ );
1048
+ };
1049
+
1050
+ // ── Main Install App ────────────────────────────────────────────
1051
+ const InstallApp = () => {
1052
+ const [screen, setScreen] = useState(SCREEN.SCOPE);
1053
+ const [scope, setScope] = useState(null);
1054
+ const [presetId, setPresetId] = useState(null);
1055
+ const [sounds, setSounds] = useState({});
1056
+ const [selectedGame, setSelectedGame] = useState(null);
1057
+ const [installResult, setInstallResult] = useState(null);
1058
+
1059
+ const initSoundsFromPreset = useCallback((pid) => {
1060
+ const preset = PRESETS[pid];
1061
+ if (preset) setSounds({ ...preset.sounds });
1062
+ }, []);
1063
+
1064
+ const content = (() => {
1065
+ switch (screen) {
1066
+ case SCREEN.SCOPE:
1067
+ return h(ScopeScreen, {
1068
+ onNext: (s) => {
1069
+ setScope(s);
1070
+ getExistingSounds(s).then((existing) => {
1071
+ if (Object.keys(existing).length > 0) setSounds(existing);
1072
+ });
1073
+ setScreen(SCREEN.PRESET);
1074
+ },
1075
+ });
1076
+
1077
+ case SCREEN.PRESET:
1078
+ return h(PresetScreen, {
1079
+ onNext: (id) => {
1080
+ if (id === "_scan") {
1081
+ setScreen(SCREEN.GAME_PICK);
1082
+ } else if (id === "_custom") {
1083
+ const firstPreset = Object.keys(PRESETS)[0];
1084
+ setPresetId(firstPreset);
1085
+ initSoundsFromPreset(firstPreset);
1086
+ setScreen(SCREEN.PREVIEW);
1087
+ } else {
1088
+ setPresetId(id);
1089
+ initSoundsFromPreset(id);
1090
+ setScreen(SCREEN.PREVIEW);
1091
+ }
1092
+ },
1093
+ onBack: () => setScreen(SCREEN.SCOPE),
1094
+ });
1095
+
1096
+ case SCREEN.PREVIEW:
1097
+ return h(PreviewScreen, {
1098
+ presetId,
1099
+ sounds,
1100
+ onAccept: (finalSounds) => {
1101
+ setSounds(finalSounds);
1102
+ setScreen(SCREEN.CONFIRM);
1103
+ },
1104
+ onBack: () => setScreen(SCREEN.PRESET),
1105
+ onUpdateSound: (eventId, path) => {
1106
+ setSounds((prev) => {
1107
+ const next = { ...prev };
1108
+ if (path === null) delete next[eventId];
1109
+ else next[eventId] = path;
1110
+ return next;
1111
+ });
1112
+ },
1113
+ });
1114
+
1115
+ case SCREEN.GAME_PICK:
1116
+ return h(GamePickScreen, {
1117
+ onNext: (gameName, gamesList) => {
1118
+ const game = gamesList.find((g) => g.name === gameName);
1119
+ const catFiles = categorizeLooseFiles(game.files);
1120
+ setSelectedGame({ ...game, files: catFiles });
1121
+ setScreen(SCREEN.GAME_SOUNDS);
1122
+ },
1123
+ onExtract: (gameName, gamesList) => {
1124
+ const game = gamesList.find((g) => g.name === gameName);
1125
+ setSelectedGame(game);
1126
+ setScreen(SCREEN.EXTRACTING);
1127
+ },
1128
+ onBack: () => setScreen(SCREEN.PRESET),
1129
+ });
1130
+
1131
+ case SCREEN.GAME_SOUNDS:
1132
+ return h(GameSoundsScreen, {
1133
+ game: selectedGame,
1134
+ sounds,
1135
+ onSelectSound: (eventId, path) => {
1136
+ setSounds((prev) => ({ ...prev, [eventId]: path }));
1137
+ },
1138
+ onDone: () => {
1139
+ setScreen(SCREEN.CONFIRM);
1140
+ },
1141
+ onBack: () => setScreen(SCREEN.GAME_PICK),
1142
+ });
1143
+
1144
+ case SCREEN.EXTRACTING:
1145
+ return h(ExtractingScreen, {
1146
+ game: selectedGame,
1147
+ onDone: (result) => {
1148
+ if (result.error || result.files.length === 0) {
1149
+ // Go back to game pick — extraction failed
1150
+ setScreen(SCREEN.GAME_PICK);
1151
+ } else {
1152
+ // Update the selected game with extracted files and go to sound picker
1153
+ setSelectedGame({
1154
+ ...selectedGame,
1155
+ files: result.files,
1156
+ fileCount: result.files.length,
1157
+ hasAudio: true,
1158
+ });
1159
+ setScreen(SCREEN.GAME_SOUNDS);
1160
+ }
1161
+ },
1162
+ onBack: () => setScreen(SCREEN.GAME_PICK),
1163
+ });
1164
+
1165
+ case SCREEN.CONFIRM:
1166
+ return h(ConfirmScreen, {
1167
+ scope,
1168
+ sounds,
1169
+ onConfirm: () => setScreen(SCREEN.INSTALLING),
1170
+ onBack: () => {
1171
+ if (selectedGame) setScreen(SCREEN.GAME_SOUNDS);
1172
+ else setScreen(SCREEN.PREVIEW);
1173
+ },
1174
+ });
1175
+
1176
+ case SCREEN.INSTALLING:
1177
+ return h(InstallingScreen, {
1178
+ scope,
1179
+ sounds,
1180
+ onDone: (result) => {
1181
+ setInstallResult(result);
1182
+ setScreen(SCREEN.DONE);
1183
+ },
1184
+ });
1185
+
1186
+ case SCREEN.DONE:
1187
+ return h(DoneScreen, { result: installResult });
1188
+
1189
+ default:
1190
+ return h(Text, { color: "red" }, "Unknown screen");
1191
+ }
1192
+ })();
1193
+
1194
+ return h(Box, { flexDirection: "column" },
1195
+ h(Header, null),
1196
+ content,
1197
+ );
1198
+ };
1199
+
1200
+ // ── Entry ───────────────────────────────────────────────────────
1201
+ export async function run() {
1202
+ const AppComponent = isUninstallMode ? UninstallApp : InstallApp;
1203
+ const instance = render(h(AppComponent));
1204
+ await instance.waitUntilExit();
1205
+ }