klaudio 0.11.2 → 0.11.3

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,1821 +1,1821 @@
1
- import React, { useState, useEffect, useCallback, useRef, useMemo } 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 { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE, isKokoroAvailable } from "./tts.js";
6
- import { playSoundWithCancel, getWavDuration } from "./player.js";
7
- import { getAvailableGames, getSystemSounds } from "./scanner.js";
8
- import { install, uninstall, getExistingSounds, checkHooksOutdated } from "./installer.js";
9
- import { getVgmstreamPath, findPackedAudioFiles, extractToWav } from "./extractor.js";
10
- import { extractUnityResource } from "./unity.js";
11
- import { extractBunFile, isBunFile } from "./scumm.js";
12
- import { getCachedExtraction, cacheExtraction, categorizeLooseFiles, getCategories, sortFilesByPriority, listCachedGames } from "./cache.js";
13
- import { basename, dirname } from "node:path";
14
- import { tmpdir } from "node:os";
15
- import { join } from "node:path";
16
-
17
- const MAX_PLAY_SECONDS = 10;
18
- const ACCENT = "#76C41E"; // Needle green-yellow midpoint
19
-
20
- /** Truncate a filename: keep first 10 chars + ext (max 4 chars) */
21
- function shortName(filePath) {
22
- const name = basename(filePath);
23
- const dot = name.lastIndexOf(".");
24
- if (dot <= 0 || name.length <= 18) return name;
25
- const stem = name.slice(0, dot);
26
- const ext = name.slice(dot);
27
- if (stem.length <= 10) return name;
28
- return stem.slice(0, 10) + "..." + ext;
29
- }
30
-
31
- const h = React.createElement;
32
-
33
- // ── Custom SelectInput components (bright colors for CMD) ────
34
- const Indicator = ({ isSelected }) =>
35
- h(Box, { marginRight: 1 }, isSelected
36
- ? h(Text, { color: ACCENT }, "❯")
37
- : h(Text, null, " "));
38
-
39
- const Item = ({ isSelected, label }) =>
40
- h(Text, { color: isSelected ? ACCENT : undefined, bold: isSelected }, label);
41
-
42
- // ── Non-wrapping SelectInput (clamps at boundaries) ─────────────
43
- const SelectInput = ({ items = [], isFocused = true, initialIndex = 0, indicatorComponent = Indicator, itemComponent = Item, limit: customLimit, onSelect, onHighlight }) => {
44
- const hasLimit = typeof customLimit === "number" && items.length > customLimit;
45
- const limit = hasLimit ? Math.min(customLimit, items.length) : items.length;
46
- const [scrollOffset, setScrollOffset] = useState(0);
47
- const [selectedIndex, setSelectedIndex] = useState(initialIndex ? Math.min(initialIndex, items.length - 1) : 0);
48
- const previousItems = useRef(items);
49
-
50
- useEffect(() => {
51
- const prevValues = previousItems.current.map((i) => i.value);
52
- const curValues = items.map((i) => i.value);
53
- if (prevValues.length !== curValues.length || prevValues.some((v, i) => v !== curValues[i])) {
54
- // Try to keep the currently selected item highlighted
55
- const prevSelected = previousItems.current[selectedIndex];
56
- const newIdx = prevSelected ? items.findIndex((i) => i.value === prevSelected.value) : -1;
57
- if (newIdx >= 0) {
58
- setSelectedIndex(newIdx);
59
- setScrollOffset(hasLimit ? Math.max(0, Math.min(newIdx, items.length - limit)) : 0);
60
- } else {
61
- // Selected item gone — reset to top
62
- setScrollOffset(0);
63
- setSelectedIndex(0);
64
- }
65
- }
66
- previousItems.current = items;
67
- }, [items]);
68
-
69
- useInput(useCallback((input, key) => {
70
- if (input === "k" || key.upArrow) {
71
- if (selectedIndex <= 0) return; // clamp — don't wrap
72
- const next = selectedIndex - 1;
73
- let newOffset = scrollOffset;
74
- if (hasLimit && next < scrollOffset) newOffset = next;
75
- setSelectedIndex(next);
76
- setScrollOffset(newOffset);
77
- if (typeof onHighlight === "function") onHighlight(items[next]);
78
- }
79
- if (input === "j" || key.downArrow) {
80
- if (selectedIndex >= items.length - 1) return; // clamp — don't wrap
81
- const next = selectedIndex + 1;
82
- let newOffset = scrollOffset;
83
- if (hasLimit && next >= scrollOffset + limit) newOffset = next - limit + 1;
84
- setSelectedIndex(next);
85
- setScrollOffset(newOffset);
86
- if (typeof onHighlight === "function") onHighlight(items[next]);
87
- }
88
- if (key.return) {
89
- if (typeof onSelect === "function") onSelect(items[selectedIndex]);
90
- }
91
- }, [hasLimit, limit, scrollOffset, selectedIndex, items, onSelect, onHighlight]), { isActive: isFocused });
92
-
93
- const visible = hasLimit ? items.slice(scrollOffset, scrollOffset + limit) : items;
94
- return h(Box, { flexDirection: "column" }, visible.map((item, index) => {
95
- const isSelected = index + scrollOffset === selectedIndex;
96
- return h(Box, { key: item.key ?? item.value },
97
- h(indicatorComponent, { isSelected }),
98
- h(itemComponent, { ...item, isSelected }));
99
- }));
100
- };
101
-
102
- // ── Screens ─────────────────────────────────────────────────────
103
- const SCREEN = {
104
- SCOPE: 0,
105
- PRESET: 1,
106
- PREVIEW: 2,
107
- SCANNING: 3,
108
- GAME_PICK: 4,
109
- GAME_SOUNDS: 5,
110
- EXTRACTING: 6,
111
- CONFIRM: 7,
112
- INSTALLING: 8,
113
- DONE: 9,
114
- MUSIC_MODE: 10,
115
- MUSIC_GAME_PICK: 11,
116
- MUSIC_PLAYING: 12,
117
- MUSIC_EXTRACTING: 13,
118
- };
119
-
120
- const isUninstallMode = process.argv.includes("--uninstall") || process.argv.includes("--remove");
121
-
122
- // ── Header component ────────────────────────────────────────────
123
- const Header = () =>
124
- h(Box, { flexDirection: "column", marginBottom: 1 },
125
- h(Text, { bold: true, color: ACCENT }, " klaudio"),
126
- h(Text, { dimColor: true }, isUninstallMode
127
- ? " Remove sound effects from Claude Code"
128
- : " Add sound effects to your Claude Code sessions"),
129
- );
130
-
131
- const NavHint = ({ back = true, extra = "" }) =>
132
- h(Box, { marginTop: 1 },
133
- h(Text, { dimColor: true },
134
- (back ? " esc back" : "") +
135
- (extra ? (back ? " • " : " ") + extra : "")
136
- ),
137
- );
138
-
139
- // ── Screen: Scope ───────────────────────────────────────────────
140
- const ScopeScreen = ({ onNext, onMusic, onUpdate, tts, onToggleTts, outdatedReasons }) => {
141
- const isOutdated = outdatedReasons && outdatedReasons.length > 0;
142
- const items = [
143
- ...(isOutdated ? [{ label: "⬆ Apply updates", value: "_update" }] : []),
144
- { label: "Global — Claude Code + Copilot (all projects)", value: "global" },
145
- { label: "This project — Claude Code + Copilot (this project only)", value: "project" },
146
- { label: "🎵 Play game music while you code", value: "_music" },
147
- ];
148
- const [sel, setSel] = useState(0);
149
- const GAP_AT = (isOutdated ? 1 : 0) + 2; // visual gap before music
150
-
151
- useInput((input, key) => {
152
- if (input === "k" || key.upArrow) {
153
- setSel((i) => Math.max(0, i - 1));
154
- } else if (input === "j" || key.downArrow) {
155
- setSel((i) => Math.min(items.length - 1, i + 1));
156
- } else if (input === "t") {
157
- onToggleTts();
158
- } else if (key.return) {
159
- const v = items[sel].value;
160
- if (v === "_update") onUpdate();
161
- else if (v === "_music") onMusic();
162
- else onNext(v);
163
- }
164
- });
165
-
166
- return h(Box, { flexDirection: "column" },
167
- isOutdated
168
- ? h(Box, { flexDirection: "column", marginLeft: 2, marginBottom: 1 },
169
- h(Text, { color: "yellow", bold: true }, " Updates available:"),
170
- ...outdatedReasons.map((r, i) =>
171
- h(Text, { key: i, color: "yellow", dimColor: true, marginLeft: 4 }, `+ ${r}`),
172
- ),
173
- )
174
- : null,
175
- h(Text, { bold: true }, " Where should sounds be installed?"),
176
- h(Box, { flexDirection: "column", marginLeft: 2 },
177
- ...items.map((item, i) => h(React.Fragment, { key: item.value },
178
- i === GAP_AT ? h(Text, { dimColor: true }, "\n ...or") : null,
179
- h(Box, null,
180
- h(Indicator, { isSelected: i === sel }),
181
- h(Item, { isSelected: i === sel, label: item.label }),
182
- ),
183
- )),
184
- ),
185
- h(Box, { marginTop: 1, marginLeft: 4 },
186
- h(Text, { color: tts ? "green" : "gray" },
187
- tts ? "🗣 Voice summary: ON" : "🗣 Voice summary: OFF",
188
- ),
189
- h(Text, { dimColor: true }, " (t to toggle)"),
190
- ),
191
- );
192
- };
193
-
194
- // ── Screen: Preset ──────────────────────────────────────────────
195
- const PresetScreen = ({ existingSounds, outdatedReasons, onNext, onReapply, onBack }) => {
196
- const hasExisting = existingSounds && Object.values(existingSounds).some(Boolean);
197
- const isOutdated = outdatedReasons && outdatedReasons.length > 0;
198
- const items = [
199
- ...(hasExisting ? [{
200
- label: isOutdated
201
- ? "⬆ Update hooks — new features available for your current sounds"
202
- : "✓ Re-apply current sounds — update config with current selections",
203
- value: "_reapply",
204
- }] : []),
205
- ...Object.entries(PRESETS).map(([id, p]) => ({
206
- label: `${p.icon} ${p.name} — ${p.description}`,
207
- value: id,
208
- })),
209
- // separator before these
210
- { label: "🔔 System sounds — use built-in OS notification sounds", value: "_system" },
211
- { label: "🕹️ Scan your games library — find sounds from Steam & Epic Games", value: "_scan" },
212
- { label: "📁 Custom files — provide your own sound files", value: "_custom" },
213
- ];
214
- const GAP_AT = (hasExisting ? 1 : 0) + Object.keys(PRESETS).length; // separator before non-preset options
215
- const [sel, setSel] = useState(0);
216
-
217
- useInput((input, key) => {
218
- if (key.escape) onBack();
219
- else if (input === "k" || key.upArrow) setSel((i) => Math.max(0, i - 1));
220
- else if (input === "j" || key.downArrow) setSel((i) => Math.min(items.length - 1, i + 1));
221
- else if (key.return) {
222
- if (items[sel].value === "_reapply") onReapply();
223
- else onNext(items[sel].value);
224
- }
225
- });
226
-
227
- return h(Box, { flexDirection: "column" },
228
- h(Text, { bold: true }, " Choose a sound preset:"),
229
- hasExisting
230
- ? h(Box, { flexDirection: "column", marginLeft: 4, marginBottom: 1 },
231
- h(Text, { dimColor: true }, "Current sounds:"),
232
- ...Object.entries(existingSounds).filter(([_, p]) => p).map(([eid, p]) =>
233
- h(Text, { key: eid, color: "green", dimColor: true }, ` ✓ ${EVENTS[eid].name}: ${shortName(p)}`),
234
- ),
235
- isOutdated
236
- ? h(Box, { flexDirection: "column", marginTop: 1 },
237
- h(Text, { color: "yellow" }, " Updates available:"),
238
- ...outdatedReasons.map((r, i) =>
239
- h(Text, { key: i, color: "yellow", dimColor: true }, ` + ${r}`),
240
- ),
241
- )
242
- : null,
243
- )
244
- : null,
245
- h(Box, { flexDirection: "column", marginLeft: 2 },
246
- ...items.map((item, i) => h(React.Fragment, { key: item.value },
247
- i === GAP_AT ? h(Text, { dimColor: true }, "\n ...or pick your own") : null,
248
- h(Box, null,
249
- h(Indicator, { isSelected: i === sel }),
250
- h(Item, { isSelected: i === sel, label: item.label }),
251
- ),
252
- )),
253
- ),
254
- h(NavHint, { back: true }),
255
- );
256
- };
257
-
258
- // ── Screen: Preview ─────────────────────────────────────────────
259
- const PreviewScreen = ({ presetId, sounds, onAccept, onBack, onUpdateSound }) => {
260
- const preset = PRESETS[presetId];
261
- const eventIds = Object.keys(EVENTS);
262
- const [currentEvent, setCurrentEvent] = useState(0);
263
- const [playing, setPlaying] = useState(false);
264
- const [elapsed, setElapsed] = useState(0);
265
- const [durations, setDurations] = useState({});
266
- const cancelRef = React.useRef(null);
267
-
268
- const eventId = eventIds[currentEvent];
269
- const eventInfo = EVENTS[eventId];
270
- const soundFile = sounds[eventId];
271
-
272
- const stopPlayback = useCallback(() => {
273
- if (cancelRef.current) { cancelRef.current(); cancelRef.current = null; }
274
- setPlaying(false);
275
- setElapsed(0);
276
- }, []);
277
-
278
- // Fetch durations for all sound files
279
- useEffect(() => {
280
- for (const [eid, path] of Object.entries(sounds)) {
281
- if (path && !durations[eid]) {
282
- getWavDuration(path).then((dur) => {
283
- if (dur != null) setDurations((d) => ({ ...d, [eid]: dur }));
284
- });
285
- }
286
- }
287
- }, [sounds]);
288
-
289
- // Auto-play when current event changes (with debounce)
290
- useEffect(() => {
291
- if (!soundFile) return;
292
- stopPlayback();
293
- const timer = setTimeout(() => {
294
- setPlaying(true);
295
- const { promise, cancel } = playSoundWithCancel(soundFile);
296
- cancelRef.current = cancel;
297
- promise.catch(() => {}).finally(() => {
298
- cancelRef.current = null;
299
- setPlaying(false);
300
- setElapsed(0);
301
- });
302
- }, 150);
303
- return () => {
304
- clearTimeout(timer);
305
- if (cancelRef.current) {
306
- cancelRef.current();
307
- cancelRef.current = null;
308
- }
309
- };
310
- }, [currentEvent]);
311
-
312
- useInput((_, key) => {
313
- if (key.escape) {
314
- if (playing) {
315
- stopPlayback();
316
- } else {
317
- onBack();
318
- }
319
- } else if (key.leftArrow || key.upArrow) {
320
- if (currentEvent > 0) {
321
- stopPlayback();
322
- setCurrentEvent((i) => i - 1);
323
- }
324
- } else if (key.rightArrow || key.downArrow) {
325
- if (currentEvent < eventIds.length - 1) {
326
- stopPlayback();
327
- setCurrentEvent((i) => i + 1);
328
- }
329
- } else if (key.return) {
330
- stopPlayback();
331
- onAccept(sounds);
332
- }
333
- });
334
-
335
- // Elapsed timer while playing
336
- useEffect(() => {
337
- if (!playing) return;
338
- setElapsed(0);
339
- const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
340
- return () => clearInterval(interval);
341
- }, [playing]);
342
-
343
- const stepLabel = `(${currentEvent + 1}/${eventIds.length})`;
344
- const dur = durations[eventId];
345
- const durLabel = dur != null ? ` (${dur > MAX_PLAY_SECONDS ? MAX_PLAY_SECONDS : dur}s)` : "";
346
-
347
- return h(Box, { flexDirection: "column" },
348
- h(Text, { bold: true }, ` ${preset.icon} ${preset.name}`),
349
- h(Text, { dimColor: true }, ` ${preset.description}`),
350
- h(Box, { marginTop: 1, flexDirection: "column" },
351
- ...eventIds.map((eid, i) => {
352
- const d = durations[eid];
353
- const dStr = d != null ? ` (${d > MAX_PLAY_SECONDS ? MAX_PLAY_SECONDS : d}s)` : "";
354
- return h(Text, { key: eid, marginLeft: 2,
355
- color: i === currentEvent ? "#00FFFF" : i < currentEvent ? "green" : "white",
356
- bold: i === currentEvent,
357
- },
358
- i < currentEvent ? " ✓ " : i === currentEvent ? " ▸ " : " ",
359
- `${EVENTS[eid].name}: `,
360
- sounds[eid] ? `${basename(sounds[eid])}${dStr}` : "(skipped)",
361
- );
362
- }),
363
- ),
364
- h(Box, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: playing ? "green" : "cyan", paddingX: 2, paddingY: 0, marginLeft: 2, marginRight: 2 },
365
- h(Text, { bold: true, color: playing ? "green" : "cyan" },
366
- `${eventInfo.name} ${stepLabel}`,
367
- ),
368
- h(Text, { dimColor: true },
369
- soundFile
370
- ? `Sound: ${basename(soundFile)}${durLabel}`
371
- : "No sound file selected",
372
- ),
373
- h(Text, { dimColor: true },
374
- `Triggers: ${eventInfo.description}`,
375
- ),
376
- playing
377
- ? h(Box, { marginTop: 1 },
378
- h(Text, { color: "green", bold: true }, h(Spinner, { type: "dots" })),
379
- h(Text, { color: "green", bold: true }, ` Now playing: ${basename(soundFile)} ${elapsed}s / ${MAX_PLAY_SECONDS}s max`),
380
- )
381
- : null,
382
- ),
383
- h(NavHint, { back: true, extra: "↑↓ switch events • enter accept all" }),
384
- );
385
- };
386
-
387
- // ── Screen: Game Pick (with progressive background scanning) ────
388
- const GamePickScreen = ({ onNext, onExtract, onBack }) => {
389
- const [games, setGames] = useState([]);
390
- const [scanning, setScanning] = useState(true);
391
- const [scanStatus, setScanStatus] = useState("Discovering game directories...");
392
- const [filter, setFilter] = useState("");
393
-
394
- // Start scanning on mount, add games progressively
395
- useEffect(() => {
396
- let cancelled = false;
397
- getAvailableGames(
398
- (progress) => {
399
- if (cancelled) return;
400
- if (progress.phase === "dirs") {
401
- setScanStatus(`Scanning ${progress.dirs.length} directories...`);
402
- } else if (progress.phase === "scanning") {
403
- setScanStatus(`Scanning: ${progress.game}`);
404
- }
405
- },
406
- (game) => {
407
- if (cancelled) return;
408
- // Add each game as it's found (only if it has audio or can extract)
409
- setGames((prev) => {
410
- if (prev.some((g) => g.name === game.name)) return prev;
411
- const next = [...prev, game];
412
- // Sort: playable first, then extractable, then others
413
- next.sort((a, b) => {
414
- if (a.hasAudio !== b.hasAudio) return a.hasAudio ? -1 : 1;
415
- if (a.canExtract !== b.canExtract) return a.canExtract ? -1 : 1;
416
- return a.name.localeCompare(b.name);
417
- });
418
- return next;
419
- });
420
- },
421
- ).then(() => {
422
- if (!cancelled) setScanning(false);
423
- }).catch(() => {
424
- if (!cancelled) setScanning(false);
425
- });
426
- return () => { cancelled = true; };
427
- }, []);
428
-
429
- useInput((input, key) => {
430
- if (key.escape) {
431
- if (filter) setFilter("");
432
- else onBack();
433
- } else if (key.backspace || key.delete) {
434
- setFilter((f) => f.slice(0, -1));
435
- } else if (input && !key.ctrl && !key.meta && input.length === 1 && input.charCodeAt(0) >= 32) {
436
- setFilter((f) => f + input);
437
- }
438
- });
439
-
440
- const usableGames = games.filter((g) => g.hasAudio || g.canExtract);
441
- const noAudio = games.filter((g) => !g.hasAudio && !g.canExtract);
442
-
443
- const allItems = usableGames
444
- .sort((a, b) => a.name.localeCompare(b.name))
445
- .map((g) => {
446
- if (g.hasAudio) {
447
- return {
448
- key: `play:${g.name}`,
449
- label: `${g.name} (${g.fileCount} audio files)`,
450
- value: `play:${g.name}`,
451
- };
452
- }
453
- const hasUnity = (g.unityAudioCount || 0) > 0;
454
- const hasPacked = (g.packedAudioCount || 0) > 0;
455
- const detail = hasUnity && !hasPacked
456
- ? `${g.unityAudioCount} Unity audio resource(s) — extract`
457
- : hasPacked && !hasUnity
458
- ? `${g.packedAudioCount} packed — extract with vgmstream`
459
- : `${g.packedAudioCount} packed + ${g.unityAudioCount} Unity — extract`;
460
- return {
461
- key: `extract:${g.name}`,
462
- label: `${g.name} (${detail})`,
463
- value: `extract:${g.name}`,
464
- };
465
- });
466
-
467
- const filterLower = filter.toLowerCase();
468
- const items = filter
469
- ? allItems.filter((i) => i.label.toLowerCase().includes(filterLower))
470
- : allItems;
471
-
472
- return h(Box, { flexDirection: "column" },
473
- scanning
474
- ? h(Box, { marginLeft: 2 },
475
- h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
476
- h(Text, null, ` ${scanStatus}`),
477
- games.length > 0
478
- ? h(Text, { color: "green" }, ` (${games.length} found)`)
479
- : null,
480
- )
481
- : h(Text, { bold: true, marginLeft: 2 },
482
- ` Found ${games.length} game(s):`,
483
- ),
484
- filter
485
- ? h(Box, { marginLeft: 4 },
486
- h(Text, { color: "yellow" }, "Filter: "),
487
- h(Text, { bold: true }, filter),
488
- h(Text, { dimColor: true }, ` (${items.length} match${items.length !== 1 ? "es" : ""})`),
489
- )
490
- : items.length > 0
491
- ? h(Text, { dimColor: true, marginLeft: 4 }, "Type to filter... (select a game while scan continues)")
492
- : null,
493
- items.length > 0
494
- ? h(Box, { marginLeft: 2 },
495
- h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item,
496
- items,
497
- limit: 15,
498
- onSelect: (item) => {
499
- const [type, ...rest] = item.value.split(":");
500
- const name = rest.join(":");
501
- if (type === "extract") onExtract(name, games);
502
- else onNext(name, games);
503
- },
504
- }),
505
- )
506
- : !scanning
507
- ? h(Text, { color: "yellow", marginLeft: 4 }, "No games with usable audio found.")
508
- : null,
509
- noAudio.length > 0 && !filter && !scanning
510
- ? h(Box, { flexDirection: "column", marginTop: 1, marginLeft: 4 },
511
- h(Text, { dimColor: true },
512
- `${noAudio.length} game(s) with no extractable audio:`,
513
- ),
514
- h(Text, { dimColor: true },
515
- noAudio.map((g) => g.name).join(", "),
516
- ),
517
- )
518
- : null,
519
- h(NavHint, { back: true }),
520
- );
521
- };
522
-
523
- // ── Screen: Game Sound Picker ───────────────────────────────────
524
- // Three-phase: category pick → file pick → preview/accept/repick
525
- const CATEGORY_LABELS = {
526
- all: "All sounds", ambient: "Ambient", music: "Music", sfx: "SFX",
527
- ui: "UI", voice: "Voice / Dialogue", creature: "Creatures / Animals", other: "Other",
528
- };
529
- const CATEGORY_ICONS = {
530
- voice: "🗣", creature: "🐾", ui: "🖱", sfx: "💥",
531
- ambient: "🌿", music: "🎵", other: "📦", all: "📂",
532
- };
533
-
534
- const FileItem = ({ isSelected, label, usedTag }) =>
535
- h(Box, null,
536
- h(Text, { color: isSelected ? ACCENT : undefined, bold: isSelected }, label),
537
- usedTag ? h(Text, { dimColor: true }, usedTag) : null,
538
- );
539
-
540
- const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
541
- const eventIds = Object.keys(EVENTS);
542
- const [currentEvent, setCurrentEvent] = useState(0);
543
- const [playing, setPlaying] = useState(false);
544
- const [elapsed, setElapsed] = useState(0);
545
- const [highlightedFile, setHighlightedFile] = useState(null);
546
- const [fileDurations, setFileDurations] = useState({});
547
- const [filter, setFilter] = useState("");
548
- const [activeCategory, setActiveCategory] = useState(null); // null = show category picker
549
- const [autoPreview, setAutoPreview] = useState(true);
550
- const [justSelected, setJustSelected] = useState(null); // brief confirmation flash
551
- const cancelRef = React.useRef(null);
552
-
553
- // Determine available categories with counts
554
- const hasCategories = game.files.some((f) => f.category);
555
- const { categories, counts } = hasCategories
556
- ? getCategories(game.files)
557
- : { categories: ["all"], counts: {} };
558
- const meaningfulCats = categories.filter((c) => c !== "all" && (counts[c] || 0) >= 2);
559
- const showCategoryPicker = meaningfulCats.length >= 2;
560
-
561
- // Sort files: voice first, then by priority (memoized for stable references)
562
- const sortedFiles = useMemo(() => hasCategories ? sortFilesByPriority(game.files) : game.files, [game.files, hasCategories]);
563
-
564
- // Filter files by category (memoized to prevent infinite re-render loops)
565
- const categoryFiles = useMemo(() => activeCategory && activeCategory !== "all"
566
- ? sortedFiles.filter((f) => f.category === activeCategory)
567
- : sortedFiles, [sortedFiles, activeCategory]);
568
-
569
- // Stop current playback helper
570
- const stopPlayback = useCallback(() => {
571
- if (cancelRef.current) {
572
- cancelRef.current();
573
- cancelRef.current = null;
574
- }
575
- setPlaying(false);
576
- setElapsed(0);
577
- }, []);
578
-
579
- // Pre-fetch durations: first 15 on category enter, ±15 around highlighted file
580
- useEffect(() => {
581
- const end = Math.min(categoryFiles.length, 15);
582
- for (let i = 0; i < end; i++) {
583
- const f = categoryFiles[i];
584
- if (!fileDurations[f.path]) {
585
- getWavDuration(f.path).then((dur) => {
586
- if (dur != null) setFileDurations((d) => ({ ...d, [f.path]: dur }));
587
- });
588
- }
589
- }
590
- }, [activeCategory]);
591
-
592
- useEffect(() => {
593
- if (!highlightedFile || highlightedFile === "_skip") return;
594
- const idx = categoryFiles.findIndex((f) => f.path === highlightedFile);
595
- if (idx < 0) return;
596
- const start = Math.max(0, idx - 15);
597
- const end = Math.min(categoryFiles.length, idx + 16);
598
- for (let i = start; i < end; i++) {
599
- const f = categoryFiles[i];
600
- if (!fileDurations[f.path]) {
601
- getWavDuration(f.path).then((dur) => {
602
- if (dur != null) setFileDurations((d) => ({ ...d, [f.path]: dur }));
603
- });
604
- }
605
- }
606
- }, [highlightedFile, categoryFiles]);
607
-
608
- // Auto-preview: play sound when highlighted file changes (with debounce)
609
- useEffect(() => {
610
- if (!autoPreview || !highlightedFile || highlightedFile === "_skip") {
611
- return;
612
- }
613
- // Cancel previous playback immediately
614
- if (cancelRef.current) {
615
- cancelRef.current();
616
- cancelRef.current = null;
617
- }
618
- setPlaying(false);
619
- setElapsed(0);
620
- // Debounce: wait 150ms before starting playback so scrubbing doesn't spam
621
- const timer = setTimeout(() => {
622
- setPlaying(true);
623
- setElapsed(0);
624
- const { promise, cancel } = playSoundWithCancel(highlightedFile);
625
- cancelRef.current = cancel;
626
- promise.catch(() => {}).finally(() => {
627
- cancelRef.current = null;
628
- setPlaying(false);
629
- setElapsed(0);
630
- });
631
- }, 150);
632
- return () => {
633
- clearTimeout(timer);
634
- if (cancelRef.current) {
635
- cancelRef.current();
636
- cancelRef.current = null;
637
- }
638
- };
639
- }, [highlightedFile, autoPreview]);
640
-
641
- useInput((input, key) => {
642
- if (key.tab) {
643
- // Tab cycles through events + Apply tab (if any sounds assigned)
644
- stopPlayback();
645
- const hasSounds = Object.values(sounds).some(Boolean);
646
- const tabCount = hasSounds ? eventIds.length + 1 : eventIds.length;
647
- setCurrentEvent((i) => (i + 1) % tabCount);
648
- } else if (key.escape) {
649
- if (playing) {
650
- stopPlayback();
651
- } else if (filter) {
652
- setFilter("");
653
- } else if (activeCategory !== null && showCategoryPicker) {
654
- stopPlayback();
655
- setActiveCategory(null);
656
- } else {
657
- stopPlayback();
658
- onBack();
659
- }
660
- } else if (input === "p" && !key.ctrl && !key.meta) {
661
- // Toggle auto-preview
662
- setAutoPreview((prev) => {
663
- if (prev) stopPlayback();
664
- return !prev;
665
- });
666
- } else if (activeCategory !== null || !showCategoryPicker) {
667
- if (key.backspace || key.delete) {
668
- setFilter((f) => f.slice(0, -1));
669
- } else if (input && input !== "p" && !key.ctrl && !key.meta && input.length === 1 && input.charCodeAt(0) >= 32) {
670
- setFilter((f) => f + input);
671
- }
672
- }
673
- });
674
-
675
- // Elapsed timer while playing
676
- useEffect(() => {
677
- if (!playing) return;
678
- setElapsed(0);
679
- const interval = setInterval(() => {
680
- setElapsed((e) => e + 1);
681
- }, 1000);
682
- return () => clearInterval(interval);
683
- }, [playing]);
684
-
685
- // Parse duration filter: "10s", "<10s", "< 10s", ">5s", "> 5s", "<=10s", ">=5s"
686
- // (must be before early returns to satisfy React hook rules)
687
- const durationFilter = useMemo(() => {
688
- const m = filter.match(/^\s*(<|>|<=|>=)?\s*(\d+(?:\.\d+)?)\s*s\s*$/);
689
- if (!m) return null;
690
- const op = m[1] || "<=";
691
- const val = parseFloat(m[2]);
692
- return { op, val };
693
- }, [filter]);
694
-
695
- const eventId = eventIds[currentEvent];
696
- const eventInfo = EVENTS[eventId];
697
- const stepLabel = `(${currentEvent + 1}/${eventIds.length})`;
698
-
699
- const advance = useCallback((selectedFile, selectedEventId) => {
700
- stopPlayback();
701
- // Show confirmation flash
702
- if (selectedFile) {
703
- setJustSelected({ file: basename(selectedFile), event: EVENTS[selectedEventId]?.name || selectedEventId });
704
- }
705
- const doAdvance = () => {
706
- setJustSelected(null);
707
- // Move to next event that hasn't been assigned yet, or wrap around
708
- const nextUnassigned = eventIds.findIndex((eid, i) => i > currentEvent && !sounds[eid]);
709
- if (nextUnassigned >= 0) {
710
- setCurrentEvent(nextUnassigned);
711
- } else {
712
- // All done or wrapped — go to next sequential
713
- setCurrentEvent((i) => Math.min(i + 1, eventIds.length - 1));
714
- }
715
- setHighlightedFile(null);
716
- setActiveCategory(null);
717
- setFilter("");
718
- };
719
- if (selectedFile) {
720
- setTimeout(doAdvance, 600);
721
- } else {
722
- doAdvance();
723
- }
724
- }, [currentEvent, eventIds, sounds, stopPlayback]);
725
-
726
- const nowPlayingFile = playing && highlightedFile && highlightedFile !== "_skip"
727
- ? highlightedFile : null;
728
-
729
- const hasAnySounds = Object.values(sounds).some(Boolean);
730
- const allAssigned = eventIds.every((eid) => sounds[eid]);
731
- // currentEvent can be eventIds.length to mean "Done" tab
732
- const onDoneTab = currentEvent >= eventIds.length;
733
-
734
- const headerBox = h(Box, { marginLeft: 2, marginBottom: 1, flexDirection: "column", borderStyle: "round", borderColor: nowPlayingFile ? "green" : ACCENT, paddingX: 2 },
735
- h(Text, { bold: true, color: nowPlayingFile ? "green" : ACCENT },
736
- `${game.name}`,
737
- ),
738
- h(Box, { marginTop: 0, gap: 2, overflowX: "hidden" },
739
- ...eventIds.map((eid, i) => {
740
- const assigned = sounds[eid];
741
- const isCurrent = i === currentEvent;
742
- const truncName = assigned ? basename(assigned).slice(0, 20) : null;
743
- const prefix = isCurrent ? "▸" : assigned ? "✓" : "·";
744
- const label = truncName
745
- ? `${prefix} ${EVENTS[eid].name}: ${truncName}`
746
- : `${prefix} ${EVENTS[eid].name}`;
747
- return h(Text, {
748
- key: eid,
749
- bold: isCurrent,
750
- color: isCurrent ? ACCENT : assigned ? "green" : "gray",
751
- }, label);
752
- }),
753
- h(Text, {
754
- key: "_done",
755
- bold: onDoneTab,
756
- color: onDoneTab ? ACCENT : hasAnySounds ? "green" : "gray",
757
- }, onDoneTab ? "▸ ✓ Apply" : hasAnySounds ? "· ✓ Apply" : "· Apply"),
758
- h(Text, { dimColor: true }, "(tab)"),
759
- ),
760
- onDoneTab
761
- ? h(Text, { dimColor: true }, "Press enter to apply your sound selections")
762
- : sounds[eventId]
763
- ? h(Text, { color: "green" }, `✓ ${basename(sounds[eventId])} — ${eventInfo.description}`)
764
- : h(Text, { dimColor: true }, `${eventInfo.description}`),
765
- );
766
-
767
- const nowPlayingBar = h(Box, { marginLeft: 2, height: 1 },
768
- justSelected
769
- ? h(Text, { color: "green", bold: true }, ` ✓ Selected "${justSelected.file}" for ${justSelected.event}`)
770
- : nowPlayingFile
771
- ? h(Box, null,
772
- h(Text, { color: "green", bold: true }, h(Spinner, { type: "dots" })),
773
- h(Text, { color: "green", bold: true }, ` Now playing: ${basename(nowPlayingFile)} ${elapsed}s / ${MAX_PLAY_SECONDS}s max`),
774
- )
775
- : h(Text, { dimColor: true }, " "),
776
- );
777
-
778
- // Apply tab: show summary and confirm
779
- if (onDoneTab) {
780
- const confirmItems = [
781
- { label: "✓ Apply sounds", value: "apply" },
782
- { label: "← Back to editing", value: "back" },
783
- ];
784
- return h(Box, { flexDirection: "column" },
785
- headerBox,
786
- h(Box, { flexDirection: "column", marginLeft: 4, marginBottom: 1 },
787
- ...eventIds.map((eid) =>
788
- h(Text, { key: eid, color: sounds[eid] ? "green" : "gray" },
789
- sounds[eid] ? ` ✓ ${EVENTS[eid].name}: ${basename(sounds[eid])}` : ` · ${EVENTS[eid].name}: (skipped)`,
790
- ),
791
- ),
792
- ),
793
- h(Box, { marginLeft: 2 },
794
- h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item,
795
- items: confirmItems,
796
- onSelect: (item) => {
797
- if (item.value === "apply") {
798
- stopPlayback();
799
- onDone();
800
- } else {
801
- setCurrentEvent(0);
802
- }
803
- },
804
- }),
805
- ),
806
- nowPlayingBar,
807
- h(NavHint, { back: true }),
808
- );
809
- }
810
-
811
- // Phase 0: Category picker
812
- if (activeCategory === null && showCategoryPicker) {
813
- const catItems = [
814
- ...meaningfulCats.map((cat) => ({
815
- label: `${CATEGORY_ICONS[cat] || "📁"} ${CATEGORY_LABELS[cat] || cat} (${counts[cat]} sounds)`,
816
- value: cat,
817
- })),
818
- { label: `${CATEGORY_ICONS.all} All sounds (${game.files.length})`, value: "all" },
819
- { label: "(skip this event)", value: "_skip" },
820
- ];
821
-
822
- return h(Box, { flexDirection: "column" },
823
- headerBox,
824
- h(Text, { bold: true, marginLeft: 4 }, "Pick a category:"),
825
- h(Box, { marginLeft: 2 },
826
- h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item,
827
- items: catItems,
828
- onSelect: (item) => {
829
- if (item.value === "_skip") {
830
- advance();
831
- } else {
832
- setActiveCategory(item.value);
833
- }
834
- },
835
- }),
836
- ),
837
- nowPlayingBar,
838
- h(NavHint, { back: true }),
839
- );
840
- }
841
-
842
- // Build a reverse map: filePath -> event name(s) it's assigned to
843
- const assignedToMap = {};
844
- for (const eid of eventIds) {
845
- if (sounds[eid]) {
846
- (assignedToMap[sounds[eid]] ||= []).push(EVENTS[eid].name);
847
- }
848
- }
849
-
850
- // Phase 1: Browse and pick files (auto-preview plays on highlight)
851
- const filterLower = filter.toLowerCase();
852
-
853
- const allFileItems = categoryFiles.map((f) => {
854
- const dur = fileDurations[f.path];
855
- const durStr = dur != null ? ` (${dur}s${dur > MAX_PLAY_SECONDS ? `, preview ${MAX_PLAY_SECONDS}s` : ""})` : "";
856
- const catTag = (!activeCategory || activeCategory === "all") && f.category && f.category !== "other"
857
- ? `[${(CATEGORY_LABELS[f.category] || f.category).toUpperCase()}] ` : "";
858
- const name = f.displayName || f.name;
859
- const usedFor = assignedToMap[f.path];
860
- return {
861
- label: `${catTag}${name}${durStr}`,
862
- usedTag: usedFor ? ` ← ${usedFor.join(", ")}` : null,
863
- value: f.path,
864
- _dur: dur,
865
- };
866
- });
867
-
868
- const filteredFiles = filter
869
- ? durationFilter
870
- ? allFileItems.filter((i) => {
871
- if (i._dur == null) return false;
872
- const { op, val } = durationFilter;
873
- if (op === "<") return i._dur < val;
874
- if (op === ">") return i._dur > val;
875
- if (op === "<=") return i._dur <= val;
876
- if (op === ">=") return i._dur >= val;
877
- return true;
878
- })
879
- : allFileItems.filter((i) => i.label.toLowerCase().includes(filterLower))
880
- : allFileItems;
881
-
882
- const fileItems = [
883
- ...filteredFiles,
884
- ...(!filter ? [{ label: "(skip this event)", value: "_skip" }] : []),
885
- ];
886
-
887
- const catLabel = activeCategory && activeCategory !== "all"
888
- ? `${CATEGORY_ICONS[activeCategory] || ""} ${CATEGORY_LABELS[activeCategory] || activeCategory}`
889
- : null;
890
-
891
- return h(Box, { flexDirection: "column" },
892
- headerBox,
893
- catLabel
894
- ? h(Text, { bold: true, color: ACCENT, marginLeft: 4 }, catLabel)
895
- : null,
896
- h(Box, { marginLeft: 4 },
897
- h(Text, { color: autoPreview ? "green" : "gray", bold: autoPreview },
898
- autoPreview ? "♫ Auto-preview ON" : "♫ Auto-preview OFF"
899
- ),
900
- h(Text, { dimColor: true }, " (p to toggle)"),
901
- ),
902
- filter
903
- ? h(Box, { marginLeft: 4 },
904
- h(Text, { color: "yellow" }, durationFilter ? "Duration: " : "Filter: "),
905
- h(Text, { bold: true }, filter),
906
- h(Text, { dimColor: true }, ` (${filteredFiles.length} match${filteredFiles.length !== 1 ? "es" : ""})`),
907
- )
908
- : categoryFiles.length > 15
909
- ? h(Text, { dimColor: true, marginLeft: 4 }, "Type to filter... (e.g. <10s, >5s)")
910
- : null,
911
- fileItems.length > 0
912
- ? h(Box, { marginLeft: 2 },
913
- h(SelectInput, { indicatorComponent: Indicator, itemComponent: FileItem,
914
- items: fileItems,
915
- limit: 15,
916
- onHighlight: (item) => {
917
- setHighlightedFile(item.value);
918
- },
919
- onSelect: (item) => {
920
- stopPlayback();
921
- if (item.value === "_skip") {
922
- advance(null, null);
923
- } else {
924
- onSelectSound(eventId, item.value);
925
- advance(item.value, eventId);
926
- }
927
- },
928
- }),
929
- )
930
- : h(Text, { color: "yellow", marginLeft: 4 }, "No matches."),
931
- nowPlayingBar,
932
- h(NavHint, { back: true, extra: "tab switch event" }),
933
- );
934
- };
935
-
936
- // ── Helpers for Music Player ─────────────────────────────────────
937
- const formatTime = (secs) => {
938
- const m = Math.floor(secs / 60);
939
- const s = Math.floor(secs % 60);
940
- return `${m}:${s.toString().padStart(2, "0")}`;
941
- };
942
-
943
- // ── Screen: Music Mode ──────────────────────────────────────────
944
- const MusicModeScreen = ({ onRandom, onPickGame, onBack }) => {
945
- useInput((_, key) => { if (key.escape) onBack(); });
946
-
947
- const items = [
948
- { label: "🎲 Shuffle all — play random songs from all cached games", value: "random" },
949
- { label: "🎮 Play songs from game — choose a game", value: "game" },
950
- ];
951
-
952
- return h(Box, { flexDirection: "column" },
953
- h(Text, { bold: true, marginLeft: 2 }, " 🎵 Music Player"),
954
- h(Text, { dimColor: true, marginLeft: 2 }, " Play longer game tracks as background music"),
955
- h(Box, { marginTop: 1, marginLeft: 2 },
956
- h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items,
957
- onSelect: (item) => {
958
- if (item.value === "random") onRandom();
959
- else onPickGame();
960
- },
961
- }),
962
- ),
963
- h(NavHint, { back: true }),
964
- );
965
- };
966
-
967
- // ── Screen: Music Game Pick (scans all installed games) ─────────
968
- const MusicGamePickScreen = ({ onNext, onExtract, onBack }) => {
969
- const [games, setGames] = useState([]);
970
- const [scanning, setScanning] = useState(true);
971
- const [scanStatus, setScanStatus] = useState("Discovering game directories...");
972
-
973
- useInput((_, key) => { if (key.escape) onBack(); });
974
-
975
- useEffect(() => {
976
- let cancelled = false;
977
- getAvailableGames(
978
- (progress) => {
979
- if (cancelled) return;
980
- if (progress.phase === "dirs") {
981
- setScanStatus(`Scanning ${progress.dirs.length} directories...`);
982
- } else if (progress.phase === "scanning") {
983
- setScanStatus(`Scanning: ${progress.game}`);
984
- }
985
- },
986
- (game) => {
987
- if (cancelled) return;
988
- if (!game.hasAudio && !game.canExtract) return; // skip games with no audio
989
- setGames((prev) => {
990
- if (prev.some((g) => g.name === game.name)) return prev;
991
- const next = [...prev, game];
992
- next.sort((a, b) => {
993
- if (a.hasAudio !== b.hasAudio) return a.hasAudio ? -1 : 1;
994
- if (a.canExtract !== b.canExtract) return a.canExtract ? -1 : 1;
995
- return a.name.localeCompare(b.name);
996
- });
997
- return next;
998
- });
999
- },
1000
- ).then(() => {
1001
- if (!cancelled) setScanning(false);
1002
- }).catch(() => {
1003
- if (!cancelled) setScanning(false);
1004
- });
1005
- return () => { cancelled = true; };
1006
- }, []);
1007
-
1008
- if (scanning && games.length === 0) {
1009
- return h(Box, { flexDirection: "column" },
1010
- h(Box, { marginLeft: 2 },
1011
- h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1012
- h(Text, null, ` ${scanStatus}`),
1013
- ),
1014
- );
1015
- }
1016
-
1017
- if (!scanning && games.length === 0) {
1018
- return h(Box, { flexDirection: "column" },
1019
- h(Text, { color: "yellow", marginLeft: 2 }, " No games with audio found."),
1020
- h(NavHint, { back: true }),
1021
- );
1022
- }
1023
-
1024
- const items = games.map((g) => {
1025
- const info = g.hasAudio ? `${g.fileCount} audio` : g.canExtract ? `${g.packedAudioCount + (g.unityAudioCount || 0)} packed` : "";
1026
- return { label: `${g.name}${info ? ` (${info})` : ""}`, value: g.name };
1027
- });
1028
-
1029
- return h(Box, { flexDirection: "column" },
1030
- h(Text, { bold: true, marginLeft: 2 }, " Pick a game:"),
1031
- scanning ? h(Box, { marginLeft: 2 },
1032
- h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1033
- h(Text, { dimColor: true }, ` ${scanStatus} (${games.length} games found)`),
1034
- ) : null,
1035
- h(Box, { marginLeft: 2 },
1036
- h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, limit: 15,
1037
- onSelect: (item) => {
1038
- const game = games.find((g) => g.name === item.value);
1039
- if (game?.hasAudio) {
1040
- onNext(game);
1041
- } else {
1042
- onExtract(game);
1043
- }
1044
- },
1045
- }),
1046
- ),
1047
- h(NavHint, { back: true }),
1048
- );
1049
- };
1050
-
1051
- // ── Screen: Music Playing ───────────────────────────────────────
1052
- const MusicPlayingScreen = ({ files, gameName, shuffle: initialShuffle, onBack }) => {
1053
- const [track, setTrack] = useState(null); // current track { path, name, displayName, duration }
1054
- const [loading, setLoading] = useState(true);
1055
- const [scanProgress, setScanProgress] = useState({ done: 0, total: files.length, found: 0 });
1056
- const [scanDone, setScanDone] = useState(false);
1057
- const [playing, setPlaying] = useState(false);
1058
- const [paused, setPaused] = useState(false);
1059
- const [elapsed, setElapsed] = useState(0);
1060
- const [pool, setPool] = useState([]);
1061
- const cancelRef = useRef(null);
1062
- const pauseRef = useRef(null);
1063
- const resumeRef = useRef(null);
1064
- const versionRef = useRef(0);
1065
- const poolRef = useRef([]); // ever-growing pool of qualifying tracks
1066
-
1067
- // Pick a random track from the pool (different from current)
1068
- const pickRandom = useCallback((currentTrack) => {
1069
- const p = poolRef.current;
1070
- if (p.length === 0) return null;
1071
- if (p.length === 1) return p[0];
1072
- let pick;
1073
- do { pick = p[Math.floor(Math.random() * p.length)]; } while (pick === currentTrack && p.length > 1);
1074
- return pick;
1075
- }, []);
1076
-
1077
- // Scan files for duration, pick first random once found, keep scanning in background
1078
- const startedRef = useRef(false);
1079
- useEffect(() => {
1080
- let cancelled = false;
1081
- (async () => {
1082
- const BATCH = 20;
1083
- for (let i = 0; i < files.length; i += BATCH) {
1084
- if (cancelled) return;
1085
- const batch = files.slice(i, i + BATCH);
1086
- const results = await Promise.all(batch.map(async (f) => {
1087
- const dur = await getWavDuration(f.path);
1088
- return { ...f, duration: dur };
1089
- }));
1090
- for (const r of results) {
1091
- if (r.duration != null && r.duration >= 30 && r.duration <= 600) {
1092
- poolRef.current.push(r);
1093
- }
1094
- }
1095
- const found = poolRef.current.length;
1096
- setScanProgress({ done: Math.min(i + BATCH, files.length), total: files.length, found });
1097
- setPool([...poolRef.current]);
1098
- // Start playing the first time we find a qualifying track
1099
- if (found >= 1 && !startedRef.current && !cancelled) {
1100
- startedRef.current = true;
1101
- setTrack(pickRandom(null));
1102
- setLoading(false);
1103
- }
1104
- }
1105
- if (!cancelled) {
1106
- setScanDone(true);
1107
- setPool([...poolRef.current]);
1108
- if (!startedRef.current) {
1109
- startedRef.current = true;
1110
- if (poolRef.current.length > 0) {
1111
- setTrack(pickRandom(null));
1112
- }
1113
- setLoading(false);
1114
- }
1115
- }
1116
- })();
1117
- return () => { cancelled = true; };
1118
- }, []);
1119
-
1120
- // Play current track
1121
- useEffect(() => {
1122
- if (!track) return;
1123
-
1124
- const myVersion = ++versionRef.current;
1125
- const { promise, cancel, pause, resume } = playSoundWithCancel(track.path, { maxSeconds: 0 });
1126
- cancelRef.current = cancel;
1127
- pauseRef.current = pause;
1128
- resumeRef.current = resume;
1129
- setPlaying(true);
1130
- setPaused(false);
1131
- setElapsed(0);
1132
-
1133
- promise.then(() => {
1134
- if (versionRef.current === myVersion) {
1135
- const next = pickRandom(track);
1136
- if (next) setTrack(next);
1137
- }
1138
- }).catch(() => {});
1139
-
1140
- return () => cancel();
1141
- }, [track]);
1142
-
1143
- // Elapsed timer
1144
- useEffect(() => {
1145
- if (!playing || paused) return;
1146
- const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
1147
- return () => clearInterval(interval);
1148
- }, [playing, paused]);
1149
-
1150
- // Controls
1151
- useInput((input, key) => {
1152
- if (key.escape) {
1153
- if (cancelRef.current) cancelRef.current();
1154
- onBack();
1155
- } else if (input === "n") {
1156
- versionRef.current++;
1157
- if (cancelRef.current) cancelRef.current();
1158
- const next = pickRandom(track);
1159
- if (next) setTrack(next);
1160
- } else if (input === " ") {
1161
- if (paused) {
1162
- if (resumeRef.current) resumeRef.current();
1163
- setPaused(false);
1164
- } else {
1165
- if (pauseRef.current) pauseRef.current();
1166
- setPaused(true);
1167
- }
1168
- }
1169
- });
1170
-
1171
- // Loading state
1172
- if (loading) {
1173
- return h(Box, { flexDirection: "column" },
1174
- h(Box, { marginLeft: 2, flexDirection: "column", borderStyle: "round", borderColor: ACCENT, paddingX: 2 },
1175
- h(Text, { bold: true, color: ACCENT }, `🎵 ${gameName || "Music Player"}`),
1176
- h(Box, { marginTop: 1 },
1177
- h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1178
- h(Text, null, ` Scanning for music tracks... ${scanProgress.found} found (${scanProgress.done}/${scanProgress.total})`),
1179
- ),
1180
- ),
1181
- h(NavHint, { back: true }),
1182
- );
1183
- }
1184
-
1185
- if (!track) {
1186
- return h(Box, { flexDirection: "column" },
1187
- h(Text, { color: "yellow", marginLeft: 2 }, " No tracks between 30s–10min found."),
1188
- h(Text, { dimColor: true, marginLeft: 2 }, " Try a different game or source."),
1189
- h(NavHint, { back: true }),
1190
- );
1191
- }
1192
-
1193
- const trackName = track.displayName || track.name || basename(track.path);
1194
-
1195
- // Build playlist items — highlight currently playing track
1196
- const playlistItems = pool.map((t) => {
1197
- const name = t.displayName || t.name || basename(t.path);
1198
- const durStr = t.duration ? ` (${formatTime(t.duration)})` : "";
1199
- const isPlaying = t.path === track.path;
1200
- return {
1201
- label: `${isPlaying ? "▶ " : " "}${name}${durStr}`,
1202
- value: t.path,
1203
- };
1204
- });
1205
-
1206
- return h(Box, { flexDirection: "column" },
1207
- h(Box, { marginLeft: 2, flexDirection: "column", borderStyle: "round", borderColor: paused ? "yellow" : "green", paddingX: 2 },
1208
- h(Text, { bold: true, color: paused ? "yellow" : "green" }, `🎵 ${gameName || "Music Player"}`),
1209
- h(Box, { marginTop: 1 },
1210
- h(Text, { color: paused ? "yellow" : "green", bold: true },
1211
- paused ? "⏸ " : "▶ ",
1212
- ),
1213
- h(Text, { bold: true }, trackName),
1214
- ),
1215
- track.gameName
1216
- ? h(Text, { dimColor: true }, ` ${track.gameName}`)
1217
- : null,
1218
- h(Text, { dimColor: true },
1219
- ` ${formatTime(elapsed)} / ${formatTime(track.duration || 0)}`,
1220
- ),
1221
- ),
1222
- h(Box, { marginTop: 1, marginLeft: 2 },
1223
- scanDone
1224
- ? h(Text, { dimColor: true }, ` ${pool.length} tracks`)
1225
- : h(Box, null,
1226
- h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1227
- h(Text, { dimColor: true }, ` ${pool.length} tracks (${scanProgress.done}/${scanProgress.total} scanned)`),
1228
- ),
1229
- ),
1230
- h(Box, { marginLeft: 2 },
1231
- h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items: playlistItems, limit: 12,
1232
- onSelect: (item) => {
1233
- const picked = poolRef.current.find((t) => t.path === item.value);
1234
- if (picked) {
1235
- versionRef.current++;
1236
- if (cancelRef.current) cancelRef.current();
1237
- setTrack(picked);
1238
- }
1239
- },
1240
- }),
1241
- ),
1242
- h(Box, { marginLeft: 4 },
1243
- h(Text, { dimColor: true }, "n random space pause esc back"),
1244
- ),
1245
- );
1246
- };
1247
-
1248
- // ── Screen: Extracting ──────────────────────────────────────────
1249
- const ExtractingScreen = ({ game, onDone, onBack }) => {
1250
- const [status, setStatus] = useState("Checking cache...");
1251
- const [extracted, setExtracted] = useState(0);
1252
-
1253
- useInput((_, key) => { if (key.escape) onBack(); });
1254
-
1255
- useEffect(() => {
1256
- let cancelled = false;
1257
-
1258
- (async () => {
1259
- try {
1260
- // Check cache first
1261
- const cached = await getCachedExtraction(game.name);
1262
- if (cached && cached.files.length > 0) {
1263
- setStatus(`Found ${cached.files.length} cached sounds`);
1264
- if (!cancelled) {
1265
- onDone({
1266
- files: cached.files.map((f) => ({
1267
- path: f.path,
1268
- name: f.name,
1269
- displayName: f.displayName,
1270
- category: f.category,
1271
- dir: dirname(f.path),
1272
- })),
1273
- categories: cached.categories,
1274
- fromCache: true,
1275
- });
1276
- }
1277
- return;
1278
- }
1279
-
1280
- const outputDir = join(tmpdir(), "klaudio-extract", game.name.replace(/[^a-zA-Z0-9]/g, "_"));
1281
- const allOutputs = [];
1282
-
1283
- // Unity .resource files — extract FSB5 banks directly (no vgmstream needed for PCM16)
1284
- const fsbFiles = []; // Vorbis .fsb files that need vgmstream conversion
1285
- if (game.unityResources && game.unityResources.length > 0) {
1286
- setStatus(`Extracting Unity audio from ${game.unityResources.length} resource file(s)...`);
1287
- for (const resPath of game.unityResources) {
1288
- if (cancelled) return;
1289
- setStatus(`Extracting: ${basename(resPath)}`);
1290
- try {
1291
- const extracted = await extractUnityResource(resPath, outputDir);
1292
- for (const f of extracted) {
1293
- if (f.path.endsWith(".wav")) {
1294
- allOutputs.push(f.path);
1295
- } else if (f.path.endsWith(".fsb")) {
1296
- fsbFiles.push({ path: f.path, name: f.name });
1297
- }
1298
- }
1299
- setExtracted(allOutputs.length);
1300
- } catch { /* skip */ }
1301
- }
1302
- }
1303
-
1304
- // Scan for packed audio files (Wwise/FMOD/BUN)
1305
- let packedFiles = [];
1306
- if (allOutputs.length === 0 || !game.unityResources?.length) {
1307
- setStatus(`Scanning ${game.name} for packed audio...`);
1308
- packedFiles = await findPackedAudioFiles(game.path, 30);
1309
- }
1310
-
1311
- // Extract BUN files natively (SCUMM engine audio)
1312
- const bunFiles = packedFiles.filter((f) => f.name.toLowerCase().endsWith(".bun"));
1313
- const nonBunFiles = packedFiles.filter((f) => !f.name.toLowerCase().endsWith(".bun"));
1314
-
1315
- for (const file of bunFiles) {
1316
- if (cancelled) return;
1317
- setStatus(`Extracting SCUMM audio: ${file.name}`);
1318
- try {
1319
- const bunOutputs = await extractBunFile(file.path, outputDir, (msg) => {
1320
- if (!cancelled) setStatus(msg);
1321
- });
1322
- allOutputs.push(...bunOutputs);
1323
- setExtracted(allOutputs.length);
1324
- } catch { /* skip */ }
1325
- }
1326
-
1327
- // Convert extracted .fsb Vorbis files via vgmstream, or handle non-BUN packed audio
1328
- const needsVgmstream = fsbFiles.length > 0 || nonBunFiles.length > 0;
1329
- if (needsVgmstream) {
1330
- // Get vgmstream-cli (downloads if needed)
1331
- setStatus("Getting vgmstream-cli...");
1332
- const vgmstream = await getVgmstreamPath((msg) => {
1333
- if (!cancelled) setStatus(msg);
1334
- });
1335
-
1336
- // Convert Unity-extracted .fsb files
1337
- for (const fsb of fsbFiles) {
1338
- if (cancelled) return;
1339
- setStatus(`Converting: ${fsb.name}`);
1340
- try {
1341
- const outputs = await extractToWav(fsb.path, outputDir, vgmstream);
1342
- allOutputs.push(...outputs);
1343
- setExtracted(allOutputs.length);
1344
- } catch { /* skip */ }
1345
- }
1346
-
1347
- // Convert non-BUN packed audio via vgmstream
1348
- for (const file of nonBunFiles) {
1349
- if (cancelled) return;
1350
- setStatus(`Extracting: ${file.name}`);
1351
- try {
1352
- const outputs = await extractToWav(file.path, outputDir, vgmstream);
1353
- allOutputs.push(...outputs);
1354
- setExtracted(allOutputs.length);
1355
- } catch { /* skip */ }
1356
- }
1357
- }
1358
-
1359
- if (allOutputs.length === 0 && fsbFiles.length === 0 && packedFiles.length === 0) {
1360
- if (!cancelled) onDone({ files: [], error: "No extractable audio files found" });
1361
- return;
1362
- }
1363
-
1364
- if (!cancelled) {
1365
- // Cache the results with category metadata
1366
- const rawFiles = allOutputs.map((p) => ({ path: p, name: basename(p) }));
1367
- setStatus("Caching extracted sounds...");
1368
- const manifest = await cacheExtraction(game.name, rawFiles, game.path);
1369
-
1370
- onDone({
1371
- files: manifest.files.map((f) => ({
1372
- path: f.path,
1373
- name: f.name,
1374
- displayName: f.displayName,
1375
- category: f.category,
1376
- dir: dirname(f.path),
1377
- })),
1378
- categories: manifest.categories,
1379
- });
1380
- }
1381
- } catch (err) {
1382
- if (!cancelled) onDone({ files: [], error: err.message });
1383
- }
1384
- })();
1385
-
1386
- return () => { cancelled = true; };
1387
- }, []);
1388
-
1389
- return h(Box, { flexDirection: "column" },
1390
- h(Box, { marginLeft: 2, flexDirection: "column", borderStyle: "round", borderColor: ACCENT, paddingX: 2 },
1391
- h(Text, { bold: true, color: ACCENT }, `Extracting audio from ${game.name}`),
1392
- h(Box, { marginTop: 1 },
1393
- h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1394
- h(Text, null, ` ${status}`),
1395
- ),
1396
- extracted > 0
1397
- ? h(Text, { color: "green" }, ` ${extracted} sound(s) extracted so far`)
1398
- : null,
1399
- ),
1400
- h(NavHint, { back: true }),
1401
- );
1402
- };
1403
-
1404
- // ── Screen: Confirm ─────────────────────────────────────────────
1405
- const ConfirmScreen = ({ scope, sounds, tts, voice, hasKokoro, onToggleTts, onCycleVoice, onConfirm, onBack }) => {
1406
- useInput((input, key) => {
1407
- if (key.escape) onBack();
1408
- else if (input === "t") onToggleTts();
1409
- else if (input === "v" && tts && hasKokoro) onCycleVoice();
1410
- });
1411
-
1412
- const items = [
1413
- { label: "✓ Yes, install!", value: "yes" },
1414
- { label: "✗ No, go back", value: "no" },
1415
- ];
1416
-
1417
- const soundEntries = Object.entries(sounds).filter(([_, path]) => path);
1418
- const voiceInfo = hasKokoro ? KOKORO_VOICES.find((v) => v.id === voice) : null;
1419
-
1420
- return h(Box, { flexDirection: "column" },
1421
- h(Text, { bold: true, marginLeft: 2 }, " Ready to install:"),
1422
- h(Box, { marginTop: 1, flexDirection: "column" },
1423
- h(Text, { marginLeft: 4 }, `Scope: ${scope === "global" ? "Global (Claude Code + Copilot)" : "This project (Claude Code + Copilot)"}`),
1424
- ...soundEntries.map(([eid, path]) =>
1425
- h(Text, { key: eid, marginLeft: 4 },
1426
- `${EVENTS[eid].name} → ${shortName(path)}`
1427
- )
1428
- ),
1429
- h(Box, { marginLeft: 4, marginTop: 1 },
1430
- h(Text, { color: tts ? "green" : "gray" },
1431
- tts ? "🗣 Voice summary: ON" : "🗣 Voice summary: OFF",
1432
- ),
1433
- h(Text, { dimColor: true }, " (t to toggle — reads a short summary when tasks complete)"),
1434
- ),
1435
- tts && voiceInfo ? h(Box, { marginLeft: 4 },
1436
- h(Text, { color: ACCENT },
1437
- `🎙 Voice: ${voiceInfo.name} (${voiceInfo.gender}, ${voiceInfo.accent})`,
1438
- ),
1439
- h(Text, { dimColor: true }, " (v to change voice)"),
1440
- ) : null,
1441
- ),
1442
- h(Box, { marginTop: 1, marginLeft: 2 },
1443
- h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, onSelect: (item) => {
1444
- if (item.value === "yes") onConfirm();
1445
- else onBack();
1446
- }}),
1447
- ),
1448
- h(NavHint, { back: true }),
1449
- );
1450
- };
1451
-
1452
- // ── Screen: Installing ──────────────────────────────────────────
1453
- const InstallingScreen = ({ scope, sounds, tts, voice, onDone }) => {
1454
- useEffect(() => {
1455
- const validSounds = {};
1456
- for (const [eventId, path] of Object.entries(sounds)) {
1457
- if (path) validSounds[eventId] = path;
1458
- }
1459
- install({ scope, sounds: validSounds, tts, voice }).then(onDone).catch((err) => {
1460
- onDone({ error: err.message });
1461
- });
1462
- }, []);
1463
-
1464
- return h(Box, { marginLeft: 2 },
1465
- h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1466
- h(Text, null, " Installing sounds..."),
1467
- );
1468
- };
1469
-
1470
- // ── Screen: Done ────────────────────────────────────────────────
1471
- const DoneScreen = ({ result }) => {
1472
- const { exit } = useApp();
1473
-
1474
- useEffect(() => {
1475
- // Play the "stop" sound as a demo if it was installed
1476
- if (result.installedSounds?.stop) {
1477
- playSoundWithCancel(result.installedSounds.stop).promise.catch(() => {});
1478
- }
1479
- const timer = setTimeout(() => exit(), 1500);
1480
- return () => clearTimeout(timer);
1481
- }, []);
1482
-
1483
- if (result.error) {
1484
- return h(Box, { flexDirection: "column", marginLeft: 2 },
1485
- h(Text, { color: "red", bold: true }, " ✗ Installation failed:"),
1486
- h(Text, { color: "red" }, ` ${result.error}`),
1487
- );
1488
- }
1489
-
1490
- return h(Box, { flexDirection: "column", marginLeft: 2 },
1491
- h(Text, { color: "green", bold: true }, " ✓ Sounds installed!"),
1492
- h(Box, { marginTop: 1, flexDirection: "column" },
1493
- h(Text, null, ` Sound files: ${result.soundsDir}`),
1494
- h(Text, null, ` Config: ${result.settingsFile}`),
1495
- ),
1496
- h(Box, { marginTop: 1, flexDirection: "column" },
1497
- h(Text, null, " Your Claude Code sessions will now play sounds for:"),
1498
- ...Object.keys(result.installedSounds).map((eventId) =>
1499
- h(Text, { key: eventId, color: "green" }, ` • ${EVENTS[eventId].name}`)
1500
- ),
1501
- ),
1502
- h(Box, { marginTop: 1 },
1503
- h(Text, { dimColor: true }, " To remove: npx klaudio --uninstall"),
1504
- ),
1505
- );
1506
- };
1507
-
1508
- // ── Uninstall App ───────────────────────────────────────────────
1509
- const UninstallApp = () => {
1510
- const { exit } = useApp();
1511
- const [phase, setPhase] = useState("scope"); // scope | working | done | notfound
1512
- const [scope, setScope] = useState(null);
1513
-
1514
- useEffect(() => {
1515
- if (phase === "working" && scope) {
1516
- uninstall(scope).then((ok) => {
1517
- setPhase(ok ? "done" : "notfound");
1518
- setTimeout(() => exit(), 500);
1519
- });
1520
- }
1521
- }, [phase, scope]);
1522
-
1523
- if (phase === "scope") {
1524
- return h(Box, { flexDirection: "column" },
1525
- h(Header, null),
1526
- h(ScopeScreen, {
1527
- onNext: (s) => { setScope(s); setPhase("working"); },
1528
- }),
1529
- );
1530
- }
1531
-
1532
- if (phase === "working") {
1533
- return h(Box, { flexDirection: "column" },
1534
- h(Header, null),
1535
- h(Box, { marginLeft: 2 },
1536
- h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1537
- h(Text, null, " Removing sounds..."),
1538
- ),
1539
- );
1540
- }
1541
-
1542
- if (phase === "done") {
1543
- return h(Box, { flexDirection: "column" },
1544
- h(Header, null),
1545
- h(Text, { color: "green", marginLeft: 2 }, " ✓ Klaudio hooks removed."),
1546
- );
1547
- }
1548
-
1549
- return h(Box, { flexDirection: "column" },
1550
- h(Header, null),
1551
- h(Text, { color: "yellow", marginLeft: 2 }, " No Klaudio configuration found."),
1552
- );
1553
- };
1554
-
1555
- // ── Main Install App ────────────────────────────────────────────
1556
- const InstallApp = () => {
1557
- const [screen, setScreen] = useState(SCREEN.SCOPE);
1558
- const [scope, setScope] = useState(null);
1559
- const [presetId, setPresetId] = useState(null);
1560
- const [sounds, setSounds] = useState({});
1561
- const [selectedGame, setSelectedGame] = useState(null);
1562
- const [installResult, setInstallResult] = useState(null);
1563
- const [tts, setTts] = useState(true);
1564
- const [voice, setVoice] = useState(KOKORO_DEFAULT_VOICE);
1565
- const [hasKokoro, setHasKokoro] = useState(false);
1566
- const [outdatedReasons, setOutdatedReasons] = useState([]);
1567
- const [musicFiles, setMusicFiles] = useState([]);
1568
- const [musicGameName, setMusicGameName] = useState(null);
1569
- const [musicShuffle, setMusicShuffle] = useState(false);
1570
-
1571
- useEffect(() => {
1572
- isKokoroAvailable().then(setHasKokoro).catch(() => {});
1573
- // Check both scopes for outdated hooks on startup
1574
- Promise.all([
1575
- checkHooksOutdated("global"),
1576
- checkHooksOutdated("project"),
1577
- ]).then(([g, p]) => {
1578
- const combined = [...new Set([...g, ...p])];
1579
- setOutdatedReasons(combined);
1580
- }).catch(() => {});
1581
- // Also pre-load existing sounds from global (most common)
1582
- getExistingSounds("global").then((existing) => {
1583
- if (Object.keys(existing).length > 0) setSounds(existing);
1584
- }).catch(() => {});
1585
- }, []);
1586
-
1587
- const initSoundsFromPreset = useCallback((pid) => {
1588
- const preset = PRESETS[pid];
1589
- if (preset) setSounds({ ...preset.sounds });
1590
- }, []);
1591
-
1592
- const content = (() => {
1593
- switch (screen) {
1594
- case SCREEN.SCOPE:
1595
- return h(ScopeScreen, {
1596
- tts,
1597
- outdatedReasons,
1598
- onToggleTts: () => setTts((v) => !v),
1599
- onNext: (s) => {
1600
- setScope(s);
1601
- // Refresh sounds/outdated for the selected scope
1602
- getExistingSounds(s).then((existing) => {
1603
- if (Object.keys(existing).length > 0) setSounds(existing);
1604
- });
1605
- checkHooksOutdated(s).then(setOutdatedReasons).catch(() => {});
1606
- setScreen(SCREEN.PRESET);
1607
- },
1608
- onUpdate: () => {
1609
- // Quick-apply: use global scope with existing sounds, skip to install
1610
- setScope("global");
1611
- setScreen(SCREEN.INSTALLING);
1612
- },
1613
- onMusic: () => setScreen(SCREEN.MUSIC_MODE),
1614
- });
1615
-
1616
- case SCREEN.PRESET:
1617
- return h(PresetScreen, {
1618
- existingSounds: sounds,
1619
- outdatedReasons,
1620
- onReapply: () => setScreen(SCREEN.CONFIRM),
1621
- onNext: (id) => {
1622
- if (id === "_music") {
1623
- setScreen(SCREEN.MUSIC_MODE);
1624
- } else if (id === "_system") {
1625
- getSystemSounds().then((files) => {
1626
- const catFiles = categorizeLooseFiles(files);
1627
- setSelectedGame({ name: "System Sounds", path: "", files: catFiles, fileCount: catFiles.length, hasAudio: catFiles.length > 0 });
1628
- setScreen(SCREEN.GAME_SOUNDS);
1629
- });
1630
- } else if (id === "_scan") {
1631
- setScreen(SCREEN.GAME_PICK);
1632
- } else if (id === "_custom") {
1633
- const firstPreset = Object.keys(PRESETS)[0];
1634
- setPresetId(firstPreset);
1635
- initSoundsFromPreset(firstPreset);
1636
- setScreen(SCREEN.PREVIEW);
1637
- } else {
1638
- setPresetId(id);
1639
- initSoundsFromPreset(id);
1640
- if (KOKORO_PRESET_VOICES[id]) setVoice(KOKORO_PRESET_VOICES[id]);
1641
- setScreen(SCREEN.PREVIEW);
1642
- }
1643
- },
1644
- onBack: () => setScreen(SCREEN.SCOPE),
1645
- });
1646
-
1647
- case SCREEN.PREVIEW:
1648
- return h(PreviewScreen, {
1649
- presetId,
1650
- sounds,
1651
- onAccept: (finalSounds) => {
1652
- setSounds(finalSounds);
1653
- setScreen(SCREEN.CONFIRM);
1654
- },
1655
- onBack: () => setScreen(SCREEN.PRESET),
1656
- onUpdateSound: (eventId, path) => {
1657
- setSounds((prev) => {
1658
- const next = { ...prev };
1659
- if (path === null) delete next[eventId];
1660
- else next[eventId] = path;
1661
- return next;
1662
- });
1663
- },
1664
- });
1665
-
1666
- case SCREEN.GAME_PICK:
1667
- return h(GamePickScreen, {
1668
- onNext: (gameName, gamesList) => {
1669
- const game = gamesList.find((g) => g.name === gameName);
1670
- const catFiles = categorizeLooseFiles(game.files);
1671
- setSelectedGame({ ...game, files: catFiles });
1672
- setScreen(SCREEN.GAME_SOUNDS);
1673
- },
1674
- onExtract: (gameName, gamesList) => {
1675
- const game = gamesList.find((g) => g.name === gameName);
1676
- setSelectedGame(game);
1677
- setScreen(SCREEN.EXTRACTING);
1678
- },
1679
- onBack: () => setScreen(SCREEN.PRESET),
1680
- });
1681
-
1682
- case SCREEN.GAME_SOUNDS:
1683
- return h(GameSoundsScreen, {
1684
- game: selectedGame,
1685
- sounds,
1686
- onSelectSound: (eventId, path) => {
1687
- setSounds((prev) => ({ ...prev, [eventId]: path }));
1688
- },
1689
- onDone: () => {
1690
- setScreen(SCREEN.CONFIRM);
1691
- },
1692
- onBack: () => setScreen(SCREEN.GAME_PICK),
1693
- });
1694
-
1695
- case SCREEN.EXTRACTING:
1696
- return h(ExtractingScreen, {
1697
- game: selectedGame,
1698
- onDone: (result) => {
1699
- if (result.error || result.files.length === 0) {
1700
- // Go back to game pick — extraction failed
1701
- setScreen(SCREEN.GAME_PICK);
1702
- } else {
1703
- // Update the selected game with extracted files and go to sound picker
1704
- setSelectedGame({
1705
- ...selectedGame,
1706
- files: result.files,
1707
- fileCount: result.files.length,
1708
- hasAudio: true,
1709
- });
1710
- setScreen(SCREEN.GAME_SOUNDS);
1711
- }
1712
- },
1713
- onBack: () => setScreen(SCREEN.GAME_PICK),
1714
- });
1715
-
1716
- case SCREEN.CONFIRM:
1717
- return h(ConfirmScreen, {
1718
- scope,
1719
- sounds,
1720
- tts,
1721
- voice,
1722
- hasKokoro,
1723
- onToggleTts: () => setTts((v) => !v),
1724
- onCycleVoice: () => setVoice((v) => {
1725
- const idx = KOKORO_VOICES.findIndex((x) => x.id === v);
1726
- return KOKORO_VOICES[(idx + 1) % KOKORO_VOICES.length].id;
1727
- }),
1728
- onConfirm: () => setScreen(SCREEN.INSTALLING),
1729
- onBack: () => {
1730
- if (selectedGame) setScreen(SCREEN.GAME_SOUNDS);
1731
- else setScreen(SCREEN.PREVIEW);
1732
- },
1733
- });
1734
-
1735
- case SCREEN.INSTALLING:
1736
- return h(InstallingScreen, {
1737
- scope,
1738
- sounds,
1739
- tts,
1740
- voice,
1741
- onDone: (result) => {
1742
- setInstallResult(result);
1743
- setScreen(SCREEN.DONE);
1744
- },
1745
- });
1746
-
1747
- case SCREEN.DONE:
1748
- return h(DoneScreen, { result: installResult });
1749
-
1750
- case SCREEN.MUSIC_MODE:
1751
- return h(MusicModeScreen, {
1752
- onRandom: () => {
1753
- listCachedGames().then((games) => {
1754
- const allFiles = games.flatMap((g) => g.files.map((f) => ({ ...f, gameName: g.gameName })));
1755
- setMusicFiles(allFiles);
1756
- setMusicGameName("All Games");
1757
- setMusicShuffle(true);
1758
- setScreen(SCREEN.MUSIC_PLAYING);
1759
- });
1760
- },
1761
- onPickGame: () => setScreen(SCREEN.MUSIC_GAME_PICK),
1762
- onBack: () => setScreen(SCREEN.SCOPE),
1763
- });
1764
-
1765
- case SCREEN.MUSIC_GAME_PICK:
1766
- return h(MusicGamePickScreen, {
1767
- onNext: (game) => {
1768
- setMusicFiles(game.files.map((f) => ({ ...f, gameName: game.name })));
1769
- setMusicGameName(game.name);
1770
- setMusicShuffle(false);
1771
- setScreen(SCREEN.MUSIC_PLAYING);
1772
- },
1773
- onExtract: (game) => {
1774
- setSelectedGame(game);
1775
- setScreen(SCREEN.MUSIC_EXTRACTING);
1776
- },
1777
- onBack: () => setScreen(SCREEN.MUSIC_MODE),
1778
- });
1779
-
1780
- case SCREEN.MUSIC_PLAYING:
1781
- return h(MusicPlayingScreen, {
1782
- files: musicFiles,
1783
- gameName: musicGameName,
1784
- shuffle: musicShuffle,
1785
- onBack: () => setScreen(SCREEN.MUSIC_MODE),
1786
- });
1787
-
1788
- case SCREEN.MUSIC_EXTRACTING:
1789
- return h(ExtractingScreen, {
1790
- game: selectedGame,
1791
- onDone: (result) => {
1792
- if (result.error || result.files.length === 0) {
1793
- setScreen(SCREEN.MUSIC_GAME_PICK);
1794
- } else {
1795
- // Go straight to playing the extracted files
1796
- setMusicFiles(result.files.map((f) => ({ ...f, gameName: selectedGame.name })));
1797
- setMusicGameName(selectedGame.name);
1798
- setMusicShuffle(true);
1799
- setScreen(SCREEN.MUSIC_PLAYING);
1800
- }
1801
- },
1802
- onBack: () => setScreen(SCREEN.MUSIC_GAME_PICK),
1803
- });
1804
-
1805
- default:
1806
- return h(Text, { color: "red" }, "Unknown screen");
1807
- }
1808
- })();
1809
-
1810
- return h(Box, { flexDirection: "column" },
1811
- h(Header, null),
1812
- content,
1813
- );
1814
- };
1815
-
1816
- // ── Entry ───────────────────────────────────────────────────────
1817
- export async function run() {
1818
- const AppComponent = isUninstallMode ? UninstallApp : InstallApp;
1819
- const instance = render(h(AppComponent));
1820
- await instance.waitUntilExit();
1821
- }
1
+ import React, { useState, useEffect, useCallback, useRef, useMemo } 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 { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE, isKokoroAvailable } from "./tts.js";
6
+ import { playSoundWithCancel, getWavDuration } from "./player.js";
7
+ import { getAvailableGames, getSystemSounds } from "./scanner.js";
8
+ import { install, uninstall, getExistingSounds, checkHooksOutdated } from "./installer.js";
9
+ import { getVgmstreamPath, findPackedAudioFiles, extractToWav } from "./extractor.js";
10
+ import { extractUnityResource } from "./unity.js";
11
+ import { extractBunFile, isBunFile } from "./scumm.js";
12
+ import { getCachedExtraction, cacheExtraction, categorizeLooseFiles, getCategories, sortFilesByPriority, listCachedGames } from "./cache.js";
13
+ import { basename, dirname } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ const MAX_PLAY_SECONDS = 10;
18
+ const ACCENT = "#76C41E"; // Needle green-yellow midpoint
19
+
20
+ /** Truncate a filename: keep first 10 chars + ext (max 4 chars) */
21
+ function shortName(filePath) {
22
+ const name = basename(filePath);
23
+ const dot = name.lastIndexOf(".");
24
+ if (dot <= 0 || name.length <= 18) return name;
25
+ const stem = name.slice(0, dot);
26
+ const ext = name.slice(dot);
27
+ if (stem.length <= 10) return name;
28
+ return stem.slice(0, 10) + "..." + ext;
29
+ }
30
+
31
+ const h = React.createElement;
32
+
33
+ // ── Custom SelectInput components (bright colors for CMD) ────
34
+ const Indicator = ({ isSelected }) =>
35
+ h(Box, { marginRight: 1 }, isSelected
36
+ ? h(Text, { color: ACCENT }, "❯")
37
+ : h(Text, null, " "));
38
+
39
+ const Item = ({ isSelected, label }) =>
40
+ h(Text, { color: isSelected ? ACCENT : undefined, bold: isSelected }, label);
41
+
42
+ // ── Non-wrapping SelectInput (clamps at boundaries) ─────────────
43
+ const SelectInput = ({ items = [], isFocused = true, initialIndex = 0, indicatorComponent = Indicator, itemComponent = Item, limit: customLimit, onSelect, onHighlight }) => {
44
+ const hasLimit = typeof customLimit === "number" && items.length > customLimit;
45
+ const limit = hasLimit ? Math.min(customLimit, items.length) : items.length;
46
+ const [scrollOffset, setScrollOffset] = useState(0);
47
+ const [selectedIndex, setSelectedIndex] = useState(initialIndex ? Math.min(initialIndex, items.length - 1) : 0);
48
+ const previousItems = useRef(items);
49
+
50
+ useEffect(() => {
51
+ const prevValues = previousItems.current.map((i) => i.value);
52
+ const curValues = items.map((i) => i.value);
53
+ if (prevValues.length !== curValues.length || prevValues.some((v, i) => v !== curValues[i])) {
54
+ // Try to keep the currently selected item highlighted
55
+ const prevSelected = previousItems.current[selectedIndex];
56
+ const newIdx = prevSelected ? items.findIndex((i) => i.value === prevSelected.value) : -1;
57
+ if (newIdx >= 0) {
58
+ setSelectedIndex(newIdx);
59
+ setScrollOffset(hasLimit ? Math.max(0, Math.min(newIdx, items.length - limit)) : 0);
60
+ } else {
61
+ // Selected item gone — reset to top
62
+ setScrollOffset(0);
63
+ setSelectedIndex(0);
64
+ }
65
+ }
66
+ previousItems.current = items;
67
+ }, [items]);
68
+
69
+ useInput(useCallback((input, key) => {
70
+ if (input === "k" || key.upArrow) {
71
+ if (selectedIndex <= 0) return; // clamp — don't wrap
72
+ const next = selectedIndex - 1;
73
+ let newOffset = scrollOffset;
74
+ if (hasLimit && next < scrollOffset) newOffset = next;
75
+ setSelectedIndex(next);
76
+ setScrollOffset(newOffset);
77
+ if (typeof onHighlight === "function") onHighlight(items[next]);
78
+ }
79
+ if (input === "j" || key.downArrow) {
80
+ if (selectedIndex >= items.length - 1) return; // clamp — don't wrap
81
+ const next = selectedIndex + 1;
82
+ let newOffset = scrollOffset;
83
+ if (hasLimit && next >= scrollOffset + limit) newOffset = next - limit + 1;
84
+ setSelectedIndex(next);
85
+ setScrollOffset(newOffset);
86
+ if (typeof onHighlight === "function") onHighlight(items[next]);
87
+ }
88
+ if (key.return) {
89
+ if (typeof onSelect === "function") onSelect(items[selectedIndex]);
90
+ }
91
+ }, [hasLimit, limit, scrollOffset, selectedIndex, items, onSelect, onHighlight]), { isActive: isFocused });
92
+
93
+ const visible = hasLimit ? items.slice(scrollOffset, scrollOffset + limit) : items;
94
+ return h(Box, { flexDirection: "column" }, visible.map((item, index) => {
95
+ const isSelected = index + scrollOffset === selectedIndex;
96
+ return h(Box, { key: item.key ?? item.value },
97
+ h(indicatorComponent, { isSelected }),
98
+ h(itemComponent, { ...item, isSelected }));
99
+ }));
100
+ };
101
+
102
+ // ── Screens ─────────────────────────────────────────────────────
103
+ const SCREEN = {
104
+ SCOPE: 0,
105
+ PRESET: 1,
106
+ PREVIEW: 2,
107
+ SCANNING: 3,
108
+ GAME_PICK: 4,
109
+ GAME_SOUNDS: 5,
110
+ EXTRACTING: 6,
111
+ CONFIRM: 7,
112
+ INSTALLING: 8,
113
+ DONE: 9,
114
+ MUSIC_MODE: 10,
115
+ MUSIC_GAME_PICK: 11,
116
+ MUSIC_PLAYING: 12,
117
+ MUSIC_EXTRACTING: 13,
118
+ };
119
+
120
+ const isUninstallMode = process.argv.includes("--uninstall") || process.argv.includes("--remove");
121
+
122
+ // ── Header component ────────────────────────────────────────────
123
+ const Header = () =>
124
+ h(Box, { flexDirection: "column", marginBottom: 1 },
125
+ h(Text, { bold: true, color: ACCENT }, " klaudio"),
126
+ h(Text, { dimColor: true }, isUninstallMode
127
+ ? " Remove sound effects from Claude Code"
128
+ : " Add sound effects to your Claude Code sessions"),
129
+ );
130
+
131
+ const NavHint = ({ back = true, extra = "" }) =>
132
+ h(Box, { marginTop: 1 },
133
+ h(Text, { dimColor: true },
134
+ (back ? " esc back" : "") +
135
+ (extra ? (back ? " • " : " ") + extra : "")
136
+ ),
137
+ );
138
+
139
+ // ── Screen: Scope ───────────────────────────────────────────────
140
+ const ScopeScreen = ({ onNext, onMusic, onUpdate, tts, onToggleTts, outdatedReasons }) => {
141
+ const isOutdated = outdatedReasons && outdatedReasons.length > 0;
142
+ const items = [
143
+ ...(isOutdated ? [{ label: "⬆ Apply updates", value: "_update" }] : []),
144
+ { label: "Global — Claude Code + Copilot (all projects)", value: "global" },
145
+ { label: "This project — Claude Code + Copilot (this project only)", value: "project" },
146
+ { label: "🎵 Play game music while you code", value: "_music" },
147
+ ];
148
+ const [sel, setSel] = useState(0);
149
+ const GAP_AT = (isOutdated ? 1 : 0) + 2; // visual gap before music
150
+
151
+ useInput((input, key) => {
152
+ if (input === "k" || key.upArrow) {
153
+ setSel((i) => Math.max(0, i - 1));
154
+ } else if (input === "j" || key.downArrow) {
155
+ setSel((i) => Math.min(items.length - 1, i + 1));
156
+ } else if (input === "t") {
157
+ onToggleTts();
158
+ } else if (key.return) {
159
+ const v = items[sel].value;
160
+ if (v === "_update") onUpdate();
161
+ else if (v === "_music") onMusic();
162
+ else onNext(v);
163
+ }
164
+ });
165
+
166
+ return h(Box, { flexDirection: "column" },
167
+ isOutdated
168
+ ? h(Box, { flexDirection: "column", marginLeft: 2, marginBottom: 1 },
169
+ h(Text, { color: "yellow", bold: true }, " Updates available:"),
170
+ ...outdatedReasons.map((r, i) =>
171
+ h(Text, { key: i, color: "yellow", dimColor: true, marginLeft: 4 }, `+ ${r}`),
172
+ ),
173
+ )
174
+ : null,
175
+ h(Text, { bold: true }, " Where should sounds be installed?"),
176
+ h(Box, { flexDirection: "column", marginLeft: 2 },
177
+ ...items.map((item, i) => h(React.Fragment, { key: item.value },
178
+ i === GAP_AT ? h(Text, { dimColor: true }, "\n ...or") : null,
179
+ h(Box, null,
180
+ h(Indicator, { isSelected: i === sel }),
181
+ h(Item, { isSelected: i === sel, label: item.label }),
182
+ ),
183
+ )),
184
+ ),
185
+ h(Box, { marginTop: 1, marginLeft: 4 },
186
+ h(Text, { color: tts ? "green" : "gray" },
187
+ tts ? "🗣 Voice summary: ON" : "🗣 Voice summary: OFF",
188
+ ),
189
+ h(Text, { dimColor: true }, " (t to toggle)"),
190
+ ),
191
+ );
192
+ };
193
+
194
+ // ── Screen: Preset ──────────────────────────────────────────────
195
+ const PresetScreen = ({ existingSounds, outdatedReasons, onNext, onReapply, onBack }) => {
196
+ const hasExisting = existingSounds && Object.values(existingSounds).some(Boolean);
197
+ const isOutdated = outdatedReasons && outdatedReasons.length > 0;
198
+ const items = [
199
+ ...(hasExisting ? [{
200
+ label: isOutdated
201
+ ? "⬆ Update hooks — new features available for your current sounds"
202
+ : "✓ Re-apply current sounds — update config with current selections",
203
+ value: "_reapply",
204
+ }] : []),
205
+ ...Object.entries(PRESETS).map(([id, p]) => ({
206
+ label: `${p.icon} ${p.name} — ${p.description}`,
207
+ value: id,
208
+ })),
209
+ // separator before these
210
+ { label: "🔔 System sounds — use built-in OS notification sounds", value: "_system" },
211
+ { label: "🕹️ Scan your games library — find sounds from Steam & Epic Games", value: "_scan" },
212
+ { label: "📁 Custom files — provide your own sound files", value: "_custom" },
213
+ ];
214
+ const GAP_AT = (hasExisting ? 1 : 0) + Object.keys(PRESETS).length; // separator before non-preset options
215
+ const [sel, setSel] = useState(0);
216
+
217
+ useInput((input, key) => {
218
+ if (key.escape) onBack();
219
+ else if (input === "k" || key.upArrow) setSel((i) => Math.max(0, i - 1));
220
+ else if (input === "j" || key.downArrow) setSel((i) => Math.min(items.length - 1, i + 1));
221
+ else if (key.return) {
222
+ if (items[sel].value === "_reapply") onReapply();
223
+ else onNext(items[sel].value);
224
+ }
225
+ });
226
+
227
+ return h(Box, { flexDirection: "column" },
228
+ h(Text, { bold: true }, " Choose a sound preset:"),
229
+ hasExisting
230
+ ? h(Box, { flexDirection: "column", marginLeft: 4, marginBottom: 1 },
231
+ h(Text, { dimColor: true }, "Current sounds:"),
232
+ ...Object.entries(existingSounds).filter(([_, p]) => p).map(([eid, p]) =>
233
+ h(Text, { key: eid, color: "green", dimColor: true }, ` ✓ ${EVENTS[eid].name}: ${shortName(p)}`),
234
+ ),
235
+ isOutdated
236
+ ? h(Box, { flexDirection: "column", marginTop: 1 },
237
+ h(Text, { color: "yellow" }, " Updates available:"),
238
+ ...outdatedReasons.map((r, i) =>
239
+ h(Text, { key: i, color: "yellow", dimColor: true }, ` + ${r}`),
240
+ ),
241
+ )
242
+ : null,
243
+ )
244
+ : null,
245
+ h(Box, { flexDirection: "column", marginLeft: 2 },
246
+ ...items.map((item, i) => h(React.Fragment, { key: item.value },
247
+ i === GAP_AT ? h(Text, { dimColor: true }, "\n ...or pick your own") : null,
248
+ h(Box, null,
249
+ h(Indicator, { isSelected: i === sel }),
250
+ h(Item, { isSelected: i === sel, label: item.label }),
251
+ ),
252
+ )),
253
+ ),
254
+ h(NavHint, { back: true }),
255
+ );
256
+ };
257
+
258
+ // ── Screen: Preview ─────────────────────────────────────────────
259
+ const PreviewScreen = ({ presetId, sounds, onAccept, onBack, onUpdateSound }) => {
260
+ const preset = PRESETS[presetId];
261
+ const eventIds = Object.keys(EVENTS);
262
+ const [currentEvent, setCurrentEvent] = useState(0);
263
+ const [playing, setPlaying] = useState(false);
264
+ const [elapsed, setElapsed] = useState(0);
265
+ const [durations, setDurations] = useState({});
266
+ const cancelRef = React.useRef(null);
267
+
268
+ const eventId = eventIds[currentEvent];
269
+ const eventInfo = EVENTS[eventId];
270
+ const soundFile = sounds[eventId];
271
+
272
+ const stopPlayback = useCallback(() => {
273
+ if (cancelRef.current) { cancelRef.current(); cancelRef.current = null; }
274
+ setPlaying(false);
275
+ setElapsed(0);
276
+ }, []);
277
+
278
+ // Fetch durations for all sound files
279
+ useEffect(() => {
280
+ for (const [eid, path] of Object.entries(sounds)) {
281
+ if (path && !durations[eid]) {
282
+ getWavDuration(path).then((dur) => {
283
+ if (dur != null) setDurations((d) => ({ ...d, [eid]: dur }));
284
+ });
285
+ }
286
+ }
287
+ }, [sounds]);
288
+
289
+ // Auto-play when current event changes (with debounce)
290
+ useEffect(() => {
291
+ if (!soundFile) return;
292
+ stopPlayback();
293
+ const timer = setTimeout(() => {
294
+ setPlaying(true);
295
+ const { promise, cancel } = playSoundWithCancel(soundFile);
296
+ cancelRef.current = cancel;
297
+ promise.catch(() => {}).finally(() => {
298
+ cancelRef.current = null;
299
+ setPlaying(false);
300
+ setElapsed(0);
301
+ });
302
+ }, 150);
303
+ return () => {
304
+ clearTimeout(timer);
305
+ if (cancelRef.current) {
306
+ cancelRef.current();
307
+ cancelRef.current = null;
308
+ }
309
+ };
310
+ }, [currentEvent]);
311
+
312
+ useInput((_, key) => {
313
+ if (key.escape) {
314
+ if (playing) {
315
+ stopPlayback();
316
+ } else {
317
+ onBack();
318
+ }
319
+ } else if (key.leftArrow || key.upArrow) {
320
+ if (currentEvent > 0) {
321
+ stopPlayback();
322
+ setCurrentEvent((i) => i - 1);
323
+ }
324
+ } else if (key.rightArrow || key.downArrow) {
325
+ if (currentEvent < eventIds.length - 1) {
326
+ stopPlayback();
327
+ setCurrentEvent((i) => i + 1);
328
+ }
329
+ } else if (key.return) {
330
+ stopPlayback();
331
+ onAccept(sounds);
332
+ }
333
+ });
334
+
335
+ // Elapsed timer while playing
336
+ useEffect(() => {
337
+ if (!playing) return;
338
+ setElapsed(0);
339
+ const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
340
+ return () => clearInterval(interval);
341
+ }, [playing]);
342
+
343
+ const stepLabel = `(${currentEvent + 1}/${eventIds.length})`;
344
+ const dur = durations[eventId];
345
+ const durLabel = dur != null ? ` (${dur > MAX_PLAY_SECONDS ? MAX_PLAY_SECONDS : dur}s)` : "";
346
+
347
+ return h(Box, { flexDirection: "column" },
348
+ h(Text, { bold: true }, ` ${preset.icon} ${preset.name}`),
349
+ h(Text, { dimColor: true }, ` ${preset.description}`),
350
+ h(Box, { marginTop: 1, flexDirection: "column" },
351
+ ...eventIds.map((eid, i) => {
352
+ const d = durations[eid];
353
+ const dStr = d != null ? ` (${d > MAX_PLAY_SECONDS ? MAX_PLAY_SECONDS : d}s)` : "";
354
+ return h(Text, { key: eid, marginLeft: 2,
355
+ color: i === currentEvent ? "#00FFFF" : i < currentEvent ? "green" : "white",
356
+ bold: i === currentEvent,
357
+ },
358
+ i < currentEvent ? " ✓ " : i === currentEvent ? " ▸ " : " ",
359
+ `${EVENTS[eid].name}: `,
360
+ sounds[eid] ? `${basename(sounds[eid])}${dStr}` : "(skipped)",
361
+ );
362
+ }),
363
+ ),
364
+ h(Box, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: playing ? "green" : "cyan", paddingX: 2, paddingY: 0, marginLeft: 2, marginRight: 2 },
365
+ h(Text, { bold: true, color: playing ? "green" : "cyan" },
366
+ `${eventInfo.name} ${stepLabel}`,
367
+ ),
368
+ h(Text, { dimColor: true },
369
+ soundFile
370
+ ? `Sound: ${basename(soundFile)}${durLabel}`
371
+ : "No sound file selected",
372
+ ),
373
+ h(Text, { dimColor: true },
374
+ `Triggers: ${eventInfo.description}`,
375
+ ),
376
+ playing
377
+ ? h(Box, { marginTop: 1 },
378
+ h(Text, { color: "green", bold: true }, h(Spinner, { type: "dots" })),
379
+ h(Text, { color: "green", bold: true }, ` Now playing: ${basename(soundFile)} ${elapsed}s / ${MAX_PLAY_SECONDS}s max`),
380
+ )
381
+ : null,
382
+ ),
383
+ h(NavHint, { back: true, extra: "↑↓ switch events • enter accept all" }),
384
+ );
385
+ };
386
+
387
+ // ── Screen: Game Pick (with progressive background scanning) ────
388
+ const GamePickScreen = ({ onNext, onExtract, onBack }) => {
389
+ const [games, setGames] = useState([]);
390
+ const [scanning, setScanning] = useState(true);
391
+ const [scanStatus, setScanStatus] = useState("Discovering game directories...");
392
+ const [filter, setFilter] = useState("");
393
+
394
+ // Start scanning on mount, add games progressively
395
+ useEffect(() => {
396
+ let cancelled = false;
397
+ getAvailableGames(
398
+ (progress) => {
399
+ if (cancelled) return;
400
+ if (progress.phase === "dirs") {
401
+ setScanStatus(`Scanning ${progress.dirs.length} directories...`);
402
+ } else if (progress.phase === "scanning") {
403
+ setScanStatus(`Scanning: ${progress.game}`);
404
+ }
405
+ },
406
+ (game) => {
407
+ if (cancelled) return;
408
+ // Add each game as it's found (only if it has audio or can extract)
409
+ setGames((prev) => {
410
+ if (prev.some((g) => g.name === game.name)) return prev;
411
+ const next = [...prev, game];
412
+ // Sort: playable first, then extractable, then others
413
+ next.sort((a, b) => {
414
+ if (a.hasAudio !== b.hasAudio) return a.hasAudio ? -1 : 1;
415
+ if (a.canExtract !== b.canExtract) return a.canExtract ? -1 : 1;
416
+ return a.name.localeCompare(b.name);
417
+ });
418
+ return next;
419
+ });
420
+ },
421
+ ).then(() => {
422
+ if (!cancelled) setScanning(false);
423
+ }).catch(() => {
424
+ if (!cancelled) setScanning(false);
425
+ });
426
+ return () => { cancelled = true; };
427
+ }, []);
428
+
429
+ useInput((input, key) => {
430
+ if (key.escape) {
431
+ if (filter) setFilter("");
432
+ else onBack();
433
+ } else if (key.backspace || key.delete) {
434
+ setFilter((f) => f.slice(0, -1));
435
+ } else if (input && !key.ctrl && !key.meta && input.length === 1 && input.charCodeAt(0) >= 32) {
436
+ setFilter((f) => f + input);
437
+ }
438
+ });
439
+
440
+ const usableGames = games.filter((g) => g.hasAudio || g.canExtract);
441
+ const noAudio = games.filter((g) => !g.hasAudio && !g.canExtract);
442
+
443
+ const allItems = usableGames
444
+ .sort((a, b) => a.name.localeCompare(b.name))
445
+ .map((g) => {
446
+ if (g.hasAudio) {
447
+ return {
448
+ key: `play:${g.name}`,
449
+ label: `${g.name} (${g.fileCount} audio files)`,
450
+ value: `play:${g.name}`,
451
+ };
452
+ }
453
+ const hasUnity = (g.unityAudioCount || 0) > 0;
454
+ const hasPacked = (g.packedAudioCount || 0) > 0;
455
+ const detail = hasUnity && !hasPacked
456
+ ? `${g.unityAudioCount} Unity audio resource(s) — extract`
457
+ : hasPacked && !hasUnity
458
+ ? `${g.packedAudioCount} packed — extract with vgmstream`
459
+ : `${g.packedAudioCount} packed + ${g.unityAudioCount} Unity — extract`;
460
+ return {
461
+ key: `extract:${g.name}`,
462
+ label: `${g.name} (${detail})`,
463
+ value: `extract:${g.name}`,
464
+ };
465
+ });
466
+
467
+ const filterLower = filter.toLowerCase();
468
+ const items = filter
469
+ ? allItems.filter((i) => i.label.toLowerCase().includes(filterLower))
470
+ : allItems;
471
+
472
+ return h(Box, { flexDirection: "column" },
473
+ scanning
474
+ ? h(Box, { marginLeft: 2 },
475
+ h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
476
+ h(Text, null, ` ${scanStatus}`),
477
+ games.length > 0
478
+ ? h(Text, { color: "green" }, ` (${games.length} found)`)
479
+ : null,
480
+ )
481
+ : h(Text, { bold: true, marginLeft: 2 },
482
+ ` Found ${games.length} game(s):`,
483
+ ),
484
+ filter
485
+ ? h(Box, { marginLeft: 4 },
486
+ h(Text, { color: "yellow" }, "Filter: "),
487
+ h(Text, { bold: true }, filter),
488
+ h(Text, { dimColor: true }, ` (${items.length} match${items.length !== 1 ? "es" : ""})`),
489
+ )
490
+ : items.length > 0
491
+ ? h(Text, { dimColor: true, marginLeft: 4 }, "Type to filter... (select a game while scan continues)")
492
+ : null,
493
+ items.length > 0
494
+ ? h(Box, { marginLeft: 2 },
495
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item,
496
+ items,
497
+ limit: 15,
498
+ onSelect: (item) => {
499
+ const [type, ...rest] = item.value.split(":");
500
+ const name = rest.join(":");
501
+ if (type === "extract") onExtract(name, games);
502
+ else onNext(name, games);
503
+ },
504
+ }),
505
+ )
506
+ : !scanning
507
+ ? h(Text, { color: "yellow", marginLeft: 4 }, "No games with usable audio found.")
508
+ : null,
509
+ noAudio.length > 0 && !filter && !scanning
510
+ ? h(Box, { flexDirection: "column", marginTop: 1, marginLeft: 4 },
511
+ h(Text, { dimColor: true },
512
+ `${noAudio.length} game(s) with no extractable audio:`,
513
+ ),
514
+ h(Text, { dimColor: true },
515
+ noAudio.map((g) => g.name).join(", "),
516
+ ),
517
+ )
518
+ : null,
519
+ h(NavHint, { back: true }),
520
+ );
521
+ };
522
+
523
+ // ── Screen: Game Sound Picker ───────────────────────────────────
524
+ // Three-phase: category pick → file pick → preview/accept/repick
525
+ const CATEGORY_LABELS = {
526
+ all: "All sounds", ambient: "Ambient", music: "Music", sfx: "SFX",
527
+ ui: "UI", voice: "Voice / Dialogue", creature: "Creatures / Animals", other: "Other",
528
+ };
529
+ const CATEGORY_ICONS = {
530
+ voice: "🗣", creature: "🐾", ui: "🖱", sfx: "💥",
531
+ ambient: "🌿", music: "🎵", other: "📦", all: "📂",
532
+ };
533
+
534
+ const FileItem = ({ isSelected, label, usedTag }) =>
535
+ h(Box, null,
536
+ h(Text, { color: isSelected ? ACCENT : undefined, bold: isSelected }, label),
537
+ usedTag ? h(Text, { dimColor: true }, usedTag) : null,
538
+ );
539
+
540
+ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
541
+ const eventIds = Object.keys(EVENTS);
542
+ const [currentEvent, setCurrentEvent] = useState(0);
543
+ const [playing, setPlaying] = useState(false);
544
+ const [elapsed, setElapsed] = useState(0);
545
+ const [highlightedFile, setHighlightedFile] = useState(null);
546
+ const [fileDurations, setFileDurations] = useState({});
547
+ const [filter, setFilter] = useState("");
548
+ const [activeCategory, setActiveCategory] = useState(null); // null = show category picker
549
+ const [autoPreview, setAutoPreview] = useState(true);
550
+ const [justSelected, setJustSelected] = useState(null); // brief confirmation flash
551
+ const cancelRef = React.useRef(null);
552
+
553
+ // Determine available categories with counts
554
+ const hasCategories = game.files.some((f) => f.category);
555
+ const { categories, counts } = hasCategories
556
+ ? getCategories(game.files)
557
+ : { categories: ["all"], counts: {} };
558
+ const meaningfulCats = categories.filter((c) => c !== "all" && (counts[c] || 0) >= 2);
559
+ const showCategoryPicker = meaningfulCats.length >= 2;
560
+
561
+ // Sort files: voice first, then by priority (memoized for stable references)
562
+ const sortedFiles = useMemo(() => hasCategories ? sortFilesByPriority(game.files) : game.files, [game.files, hasCategories]);
563
+
564
+ // Filter files by category (memoized to prevent infinite re-render loops)
565
+ const categoryFiles = useMemo(() => activeCategory && activeCategory !== "all"
566
+ ? sortedFiles.filter((f) => f.category === activeCategory)
567
+ : sortedFiles, [sortedFiles, activeCategory]);
568
+
569
+ // Stop current playback helper
570
+ const stopPlayback = useCallback(() => {
571
+ if (cancelRef.current) {
572
+ cancelRef.current();
573
+ cancelRef.current = null;
574
+ }
575
+ setPlaying(false);
576
+ setElapsed(0);
577
+ }, []);
578
+
579
+ // Pre-fetch durations: first 15 on category enter, ±15 around highlighted file
580
+ useEffect(() => {
581
+ const end = Math.min(categoryFiles.length, 15);
582
+ for (let i = 0; i < end; i++) {
583
+ const f = categoryFiles[i];
584
+ if (!fileDurations[f.path]) {
585
+ getWavDuration(f.path).then((dur) => {
586
+ if (dur != null) setFileDurations((d) => ({ ...d, [f.path]: dur }));
587
+ });
588
+ }
589
+ }
590
+ }, [activeCategory]);
591
+
592
+ useEffect(() => {
593
+ if (!highlightedFile || highlightedFile === "_skip") return;
594
+ const idx = categoryFiles.findIndex((f) => f.path === highlightedFile);
595
+ if (idx < 0) return;
596
+ const start = Math.max(0, idx - 15);
597
+ const end = Math.min(categoryFiles.length, idx + 16);
598
+ for (let i = start; i < end; i++) {
599
+ const f = categoryFiles[i];
600
+ if (!fileDurations[f.path]) {
601
+ getWavDuration(f.path).then((dur) => {
602
+ if (dur != null) setFileDurations((d) => ({ ...d, [f.path]: dur }));
603
+ });
604
+ }
605
+ }
606
+ }, [highlightedFile, categoryFiles]);
607
+
608
+ // Auto-preview: play sound when highlighted file changes (with debounce)
609
+ useEffect(() => {
610
+ if (!autoPreview || !highlightedFile || highlightedFile === "_skip") {
611
+ return;
612
+ }
613
+ // Cancel previous playback immediately
614
+ if (cancelRef.current) {
615
+ cancelRef.current();
616
+ cancelRef.current = null;
617
+ }
618
+ setPlaying(false);
619
+ setElapsed(0);
620
+ // Debounce: wait 150ms before starting playback so scrubbing doesn't spam
621
+ const timer = setTimeout(() => {
622
+ setPlaying(true);
623
+ setElapsed(0);
624
+ const { promise, cancel } = playSoundWithCancel(highlightedFile);
625
+ cancelRef.current = cancel;
626
+ promise.catch(() => {}).finally(() => {
627
+ cancelRef.current = null;
628
+ setPlaying(false);
629
+ setElapsed(0);
630
+ });
631
+ }, 150);
632
+ return () => {
633
+ clearTimeout(timer);
634
+ if (cancelRef.current) {
635
+ cancelRef.current();
636
+ cancelRef.current = null;
637
+ }
638
+ };
639
+ }, [highlightedFile, autoPreview]);
640
+
641
+ useInput((input, key) => {
642
+ if (key.tab) {
643
+ // Tab cycles through events + Apply tab (if any sounds assigned)
644
+ stopPlayback();
645
+ const hasSounds = Object.values(sounds).some(Boolean);
646
+ const tabCount = hasSounds ? eventIds.length + 1 : eventIds.length;
647
+ setCurrentEvent((i) => (i + 1) % tabCount);
648
+ } else if (key.escape) {
649
+ if (playing) {
650
+ stopPlayback();
651
+ } else if (filter) {
652
+ setFilter("");
653
+ } else if (activeCategory !== null && showCategoryPicker) {
654
+ stopPlayback();
655
+ setActiveCategory(null);
656
+ } else {
657
+ stopPlayback();
658
+ onBack();
659
+ }
660
+ } else if (input === "p" && !key.ctrl && !key.meta) {
661
+ // Toggle auto-preview
662
+ setAutoPreview((prev) => {
663
+ if (prev) stopPlayback();
664
+ return !prev;
665
+ });
666
+ } else if (activeCategory !== null || !showCategoryPicker) {
667
+ if (key.backspace || key.delete) {
668
+ setFilter((f) => f.slice(0, -1));
669
+ } else if (input && input !== "p" && !key.ctrl && !key.meta && input.length === 1 && input.charCodeAt(0) >= 32) {
670
+ setFilter((f) => f + input);
671
+ }
672
+ }
673
+ });
674
+
675
+ // Elapsed timer while playing
676
+ useEffect(() => {
677
+ if (!playing) return;
678
+ setElapsed(0);
679
+ const interval = setInterval(() => {
680
+ setElapsed((e) => e + 1);
681
+ }, 1000);
682
+ return () => clearInterval(interval);
683
+ }, [playing]);
684
+
685
+ // Parse duration filter: "10s", "<10s", "< 10s", ">5s", "> 5s", "<=10s", ">=5s"
686
+ // (must be before early returns to satisfy React hook rules)
687
+ const durationFilter = useMemo(() => {
688
+ const m = filter.match(/^\s*(<|>|<=|>=)?\s*(\d+(?:\.\d+)?)\s*s\s*$/);
689
+ if (!m) return null;
690
+ const op = m[1] || "<=";
691
+ const val = parseFloat(m[2]);
692
+ return { op, val };
693
+ }, [filter]);
694
+
695
+ const eventId = eventIds[currentEvent];
696
+ const eventInfo = EVENTS[eventId];
697
+ const stepLabel = `(${currentEvent + 1}/${eventIds.length})`;
698
+
699
+ const advance = useCallback((selectedFile, selectedEventId) => {
700
+ stopPlayback();
701
+ // Show confirmation flash
702
+ if (selectedFile) {
703
+ setJustSelected({ file: basename(selectedFile), event: EVENTS[selectedEventId]?.name || selectedEventId });
704
+ }
705
+ const doAdvance = () => {
706
+ setJustSelected(null);
707
+ // Move to next event that hasn't been assigned yet, or wrap around
708
+ const nextUnassigned = eventIds.findIndex((eid, i) => i > currentEvent && !sounds[eid]);
709
+ if (nextUnassigned >= 0) {
710
+ setCurrentEvent(nextUnassigned);
711
+ } else {
712
+ // All done or wrapped — go to next sequential
713
+ setCurrentEvent((i) => Math.min(i + 1, eventIds.length - 1));
714
+ }
715
+ setHighlightedFile(null);
716
+ setActiveCategory(null);
717
+ setFilter("");
718
+ };
719
+ if (selectedFile) {
720
+ setTimeout(doAdvance, 600);
721
+ } else {
722
+ doAdvance();
723
+ }
724
+ }, [currentEvent, eventIds, sounds, stopPlayback]);
725
+
726
+ const nowPlayingFile = playing && highlightedFile && highlightedFile !== "_skip"
727
+ ? highlightedFile : null;
728
+
729
+ const hasAnySounds = Object.values(sounds).some(Boolean);
730
+ const allAssigned = eventIds.every((eid) => sounds[eid]);
731
+ // currentEvent can be eventIds.length to mean "Done" tab
732
+ const onDoneTab = currentEvent >= eventIds.length;
733
+
734
+ const headerBox = h(Box, { marginLeft: 2, marginBottom: 1, flexDirection: "column", borderStyle: "round", borderColor: nowPlayingFile ? "green" : ACCENT, paddingX: 2 },
735
+ h(Text, { bold: true, color: nowPlayingFile ? "green" : ACCENT },
736
+ `${game.name}`,
737
+ ),
738
+ h(Box, { marginTop: 0, gap: 2, overflowX: "hidden" },
739
+ ...eventIds.map((eid, i) => {
740
+ const assigned = sounds[eid];
741
+ const isCurrent = i === currentEvent;
742
+ const truncName = assigned ? basename(assigned).slice(0, 20) : null;
743
+ const prefix = isCurrent ? "▸" : assigned ? "✓" : "·";
744
+ const label = truncName
745
+ ? `${prefix} ${EVENTS[eid].name}: ${truncName}`
746
+ : `${prefix} ${EVENTS[eid].name}`;
747
+ return h(Text, {
748
+ key: eid,
749
+ bold: isCurrent,
750
+ color: isCurrent ? ACCENT : assigned ? "green" : "gray",
751
+ }, label);
752
+ }),
753
+ h(Text, {
754
+ key: "_done",
755
+ bold: onDoneTab,
756
+ color: onDoneTab ? ACCENT : hasAnySounds ? "green" : "gray",
757
+ }, onDoneTab ? "▸ ✓ Apply" : hasAnySounds ? "· ✓ Apply" : "· Apply"),
758
+ h(Text, { dimColor: true }, "(tab)"),
759
+ ),
760
+ onDoneTab
761
+ ? h(Text, { dimColor: true }, "Press enter to apply your sound selections")
762
+ : sounds[eventId]
763
+ ? h(Text, { color: "green" }, `✓ ${basename(sounds[eventId])} — ${eventInfo.description}`)
764
+ : h(Text, { dimColor: true }, `${eventInfo.description}`),
765
+ );
766
+
767
+ const nowPlayingBar = h(Box, { marginLeft: 2, height: 1 },
768
+ justSelected
769
+ ? h(Text, { color: "green", bold: true }, ` ✓ Selected "${justSelected.file}" for ${justSelected.event}`)
770
+ : nowPlayingFile
771
+ ? h(Box, null,
772
+ h(Text, { color: "green", bold: true }, h(Spinner, { type: "dots" })),
773
+ h(Text, { color: "green", bold: true }, ` Now playing: ${basename(nowPlayingFile)} ${elapsed}s / ${MAX_PLAY_SECONDS}s max`),
774
+ )
775
+ : h(Text, { dimColor: true }, " "),
776
+ );
777
+
778
+ // Apply tab: show summary and confirm
779
+ if (onDoneTab) {
780
+ const confirmItems = [
781
+ { label: "✓ Apply sounds", value: "apply" },
782
+ { label: "← Back to editing", value: "back" },
783
+ ];
784
+ return h(Box, { flexDirection: "column" },
785
+ headerBox,
786
+ h(Box, { flexDirection: "column", marginLeft: 4, marginBottom: 1 },
787
+ ...eventIds.map((eid) =>
788
+ h(Text, { key: eid, color: sounds[eid] ? "green" : "gray" },
789
+ sounds[eid] ? ` ✓ ${EVENTS[eid].name}: ${basename(sounds[eid])}` : ` · ${EVENTS[eid].name}: (skipped)`,
790
+ ),
791
+ ),
792
+ ),
793
+ h(Box, { marginLeft: 2 },
794
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item,
795
+ items: confirmItems,
796
+ onSelect: (item) => {
797
+ if (item.value === "apply") {
798
+ stopPlayback();
799
+ onDone();
800
+ } else {
801
+ setCurrentEvent(0);
802
+ }
803
+ },
804
+ }),
805
+ ),
806
+ nowPlayingBar,
807
+ h(NavHint, { back: true }),
808
+ );
809
+ }
810
+
811
+ // Phase 0: Category picker
812
+ if (activeCategory === null && showCategoryPicker) {
813
+ const catItems = [
814
+ ...meaningfulCats.map((cat) => ({
815
+ label: `${CATEGORY_ICONS[cat] || "📁"} ${CATEGORY_LABELS[cat] || cat} (${counts[cat]} sounds)`,
816
+ value: cat,
817
+ })),
818
+ { label: `${CATEGORY_ICONS.all} All sounds (${game.files.length})`, value: "all" },
819
+ { label: "(skip this event)", value: "_skip" },
820
+ ];
821
+
822
+ return h(Box, { flexDirection: "column" },
823
+ headerBox,
824
+ h(Text, { bold: true, marginLeft: 4 }, "Pick a category:"),
825
+ h(Box, { marginLeft: 2 },
826
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item,
827
+ items: catItems,
828
+ onSelect: (item) => {
829
+ if (item.value === "_skip") {
830
+ advance();
831
+ } else {
832
+ setActiveCategory(item.value);
833
+ }
834
+ },
835
+ }),
836
+ ),
837
+ nowPlayingBar,
838
+ h(NavHint, { back: true }),
839
+ );
840
+ }
841
+
842
+ // Build a reverse map: filePath -> event name(s) it's assigned to
843
+ const assignedToMap = {};
844
+ for (const eid of eventIds) {
845
+ if (sounds[eid]) {
846
+ (assignedToMap[sounds[eid]] ||= []).push(EVENTS[eid].name);
847
+ }
848
+ }
849
+
850
+ // Phase 1: Browse and pick files (auto-preview plays on highlight)
851
+ const filterLower = filter.toLowerCase();
852
+
853
+ const allFileItems = categoryFiles.map((f) => {
854
+ const dur = fileDurations[f.path];
855
+ const durStr = dur != null ? ` (${dur}s${dur > MAX_PLAY_SECONDS ? `, preview ${MAX_PLAY_SECONDS}s` : ""})` : "";
856
+ const catTag = (!activeCategory || activeCategory === "all") && f.category && f.category !== "other"
857
+ ? `[${(CATEGORY_LABELS[f.category] || f.category).toUpperCase()}] ` : "";
858
+ const name = f.displayName || f.name;
859
+ const usedFor = assignedToMap[f.path];
860
+ return {
861
+ label: `${catTag}${name}${durStr}`,
862
+ usedTag: usedFor ? ` ← ${usedFor.join(", ")}` : null,
863
+ value: f.path,
864
+ _dur: dur,
865
+ };
866
+ });
867
+
868
+ const filteredFiles = filter
869
+ ? durationFilter
870
+ ? allFileItems.filter((i) => {
871
+ if (i._dur == null) return false;
872
+ const { op, val } = durationFilter;
873
+ if (op === "<") return i._dur < val;
874
+ if (op === ">") return i._dur > val;
875
+ if (op === "<=") return i._dur <= val;
876
+ if (op === ">=") return i._dur >= val;
877
+ return true;
878
+ })
879
+ : allFileItems.filter((i) => i.label.toLowerCase().includes(filterLower))
880
+ : allFileItems;
881
+
882
+ const fileItems = [
883
+ ...filteredFiles,
884
+ ...(!filter ? [{ label: "(skip this event)", value: "_skip" }] : []),
885
+ ];
886
+
887
+ const catLabel = activeCategory && activeCategory !== "all"
888
+ ? `${CATEGORY_ICONS[activeCategory] || ""} ${CATEGORY_LABELS[activeCategory] || activeCategory}`
889
+ : null;
890
+
891
+ return h(Box, { flexDirection: "column" },
892
+ headerBox,
893
+ catLabel
894
+ ? h(Text, { bold: true, color: ACCENT, marginLeft: 4 }, catLabel)
895
+ : null,
896
+ h(Box, { marginLeft: 4 },
897
+ h(Text, { color: autoPreview ? "green" : "gray", bold: autoPreview },
898
+ autoPreview ? "♫ Auto-preview ON" : "♫ Auto-preview OFF"
899
+ ),
900
+ h(Text, { dimColor: true }, " (p to toggle)"),
901
+ ),
902
+ filter
903
+ ? h(Box, { marginLeft: 4 },
904
+ h(Text, { color: "yellow" }, durationFilter ? "Duration: " : "Filter: "),
905
+ h(Text, { bold: true }, filter),
906
+ h(Text, { dimColor: true }, ` (${filteredFiles.length} match${filteredFiles.length !== 1 ? "es" : ""})`),
907
+ )
908
+ : categoryFiles.length > 15
909
+ ? h(Text, { dimColor: true, marginLeft: 4 }, "Type to filter... (e.g. <10s, >5s)")
910
+ : null,
911
+ fileItems.length > 0
912
+ ? h(Box, { marginLeft: 2 },
913
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: FileItem,
914
+ items: fileItems,
915
+ limit: 15,
916
+ onHighlight: (item) => {
917
+ setHighlightedFile(item.value);
918
+ },
919
+ onSelect: (item) => {
920
+ stopPlayback();
921
+ if (item.value === "_skip") {
922
+ advance(null, null);
923
+ } else {
924
+ onSelectSound(eventId, item.value);
925
+ advance(item.value, eventId);
926
+ }
927
+ },
928
+ }),
929
+ )
930
+ : h(Text, { color: "yellow", marginLeft: 4 }, "No matches."),
931
+ nowPlayingBar,
932
+ h(NavHint, { back: true, extra: "tab switch event" }),
933
+ );
934
+ };
935
+
936
+ // ── Helpers for Music Player ─────────────────────────────────────
937
+ const formatTime = (secs) => {
938
+ const m = Math.floor(secs / 60);
939
+ const s = Math.floor(secs % 60);
940
+ return `${m}:${s.toString().padStart(2, "0")}`;
941
+ };
942
+
943
+ // ── Screen: Music Mode ──────────────────────────────────────────
944
+ const MusicModeScreen = ({ onRandom, onPickGame, onBack }) => {
945
+ useInput((_, key) => { if (key.escape) onBack(); });
946
+
947
+ const items = [
948
+ { label: "🎲 Shuffle all — play random songs from all cached games", value: "random" },
949
+ { label: "🎮 Play songs from game — choose a game", value: "game" },
950
+ ];
951
+
952
+ return h(Box, { flexDirection: "column" },
953
+ h(Text, { bold: true, marginLeft: 2 }, " 🎵 Music Player"),
954
+ h(Text, { dimColor: true, marginLeft: 2 }, " Play longer game tracks as background music"),
955
+ h(Box, { marginTop: 1, marginLeft: 2 },
956
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items,
957
+ onSelect: (item) => {
958
+ if (item.value === "random") onRandom();
959
+ else onPickGame();
960
+ },
961
+ }),
962
+ ),
963
+ h(NavHint, { back: true }),
964
+ );
965
+ };
966
+
967
+ // ── Screen: Music Game Pick (scans all installed games) ─────────
968
+ const MusicGamePickScreen = ({ onNext, onExtract, onBack }) => {
969
+ const [games, setGames] = useState([]);
970
+ const [scanning, setScanning] = useState(true);
971
+ const [scanStatus, setScanStatus] = useState("Discovering game directories...");
972
+
973
+ useInput((_, key) => { if (key.escape) onBack(); });
974
+
975
+ useEffect(() => {
976
+ let cancelled = false;
977
+ getAvailableGames(
978
+ (progress) => {
979
+ if (cancelled) return;
980
+ if (progress.phase === "dirs") {
981
+ setScanStatus(`Scanning ${progress.dirs.length} directories...`);
982
+ } else if (progress.phase === "scanning") {
983
+ setScanStatus(`Scanning: ${progress.game}`);
984
+ }
985
+ },
986
+ (game) => {
987
+ if (cancelled) return;
988
+ if (!game.hasAudio && !game.canExtract) return; // skip games with no audio
989
+ setGames((prev) => {
990
+ if (prev.some((g) => g.name === game.name)) return prev;
991
+ const next = [...prev, game];
992
+ next.sort((a, b) => {
993
+ if (a.hasAudio !== b.hasAudio) return a.hasAudio ? -1 : 1;
994
+ if (a.canExtract !== b.canExtract) return a.canExtract ? -1 : 1;
995
+ return a.name.localeCompare(b.name);
996
+ });
997
+ return next;
998
+ });
999
+ },
1000
+ ).then(() => {
1001
+ if (!cancelled) setScanning(false);
1002
+ }).catch(() => {
1003
+ if (!cancelled) setScanning(false);
1004
+ });
1005
+ return () => { cancelled = true; };
1006
+ }, []);
1007
+
1008
+ if (scanning && games.length === 0) {
1009
+ return h(Box, { flexDirection: "column" },
1010
+ h(Box, { marginLeft: 2 },
1011
+ h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1012
+ h(Text, null, ` ${scanStatus}`),
1013
+ ),
1014
+ );
1015
+ }
1016
+
1017
+ if (!scanning && games.length === 0) {
1018
+ return h(Box, { flexDirection: "column" },
1019
+ h(Text, { color: "yellow", marginLeft: 2 }, " No games with audio found."),
1020
+ h(NavHint, { back: true }),
1021
+ );
1022
+ }
1023
+
1024
+ const items = games.map((g) => {
1025
+ const info = g.hasAudio ? `${g.fileCount} audio` : g.canExtract ? `${g.packedAudioCount + (g.unityAudioCount || 0)} packed` : "";
1026
+ return { label: `${g.name}${info ? ` (${info})` : ""}`, value: g.name };
1027
+ });
1028
+
1029
+ return h(Box, { flexDirection: "column" },
1030
+ h(Text, { bold: true, marginLeft: 2 }, " Pick a game:"),
1031
+ scanning ? h(Box, { marginLeft: 2 },
1032
+ h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1033
+ h(Text, { dimColor: true }, ` ${scanStatus} (${games.length} games found)`),
1034
+ ) : null,
1035
+ h(Box, { marginLeft: 2 },
1036
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, limit: 15,
1037
+ onSelect: (item) => {
1038
+ const game = games.find((g) => g.name === item.value);
1039
+ if (game?.hasAudio) {
1040
+ onNext(game);
1041
+ } else {
1042
+ onExtract(game);
1043
+ }
1044
+ },
1045
+ }),
1046
+ ),
1047
+ h(NavHint, { back: true }),
1048
+ );
1049
+ };
1050
+
1051
+ // ── Screen: Music Playing ───────────────────────────────────────
1052
+ const MusicPlayingScreen = ({ files, gameName, shuffle: initialShuffle, onBack }) => {
1053
+ const [track, setTrack] = useState(null); // current track { path, name, displayName, duration }
1054
+ const [loading, setLoading] = useState(true);
1055
+ const [scanProgress, setScanProgress] = useState({ done: 0, total: files.length, found: 0 });
1056
+ const [scanDone, setScanDone] = useState(false);
1057
+ const [playing, setPlaying] = useState(false);
1058
+ const [paused, setPaused] = useState(false);
1059
+ const [elapsed, setElapsed] = useState(0);
1060
+ const [pool, setPool] = useState([]);
1061
+ const cancelRef = useRef(null);
1062
+ const pauseRef = useRef(null);
1063
+ const resumeRef = useRef(null);
1064
+ const versionRef = useRef(0);
1065
+ const poolRef = useRef([]); // ever-growing pool of qualifying tracks
1066
+
1067
+ // Pick a random track from the pool (different from current)
1068
+ const pickRandom = useCallback((currentTrack) => {
1069
+ const p = poolRef.current;
1070
+ if (p.length === 0) return null;
1071
+ if (p.length === 1) return p[0];
1072
+ let pick;
1073
+ do { pick = p[Math.floor(Math.random() * p.length)]; } while (pick === currentTrack && p.length > 1);
1074
+ return pick;
1075
+ }, []);
1076
+
1077
+ // Scan files for duration, pick first random once found, keep scanning in background
1078
+ const startedRef = useRef(false);
1079
+ useEffect(() => {
1080
+ let cancelled = false;
1081
+ (async () => {
1082
+ const BATCH = 20;
1083
+ for (let i = 0; i < files.length; i += BATCH) {
1084
+ if (cancelled) return;
1085
+ const batch = files.slice(i, i + BATCH);
1086
+ const results = await Promise.all(batch.map(async (f) => {
1087
+ const dur = await getWavDuration(f.path);
1088
+ return { ...f, duration: dur };
1089
+ }));
1090
+ for (const r of results) {
1091
+ if (r.duration != null && r.duration >= 30 && r.duration <= 600) {
1092
+ poolRef.current.push(r);
1093
+ }
1094
+ }
1095
+ const found = poolRef.current.length;
1096
+ setScanProgress({ done: Math.min(i + BATCH, files.length), total: files.length, found });
1097
+ setPool([...poolRef.current]);
1098
+ // Start playing the first time we find a qualifying track
1099
+ if (found >= 1 && !startedRef.current && !cancelled) {
1100
+ startedRef.current = true;
1101
+ setTrack(pickRandom(null));
1102
+ setLoading(false);
1103
+ }
1104
+ }
1105
+ if (!cancelled) {
1106
+ setScanDone(true);
1107
+ setPool([...poolRef.current]);
1108
+ if (!startedRef.current) {
1109
+ startedRef.current = true;
1110
+ if (poolRef.current.length > 0) {
1111
+ setTrack(pickRandom(null));
1112
+ }
1113
+ setLoading(false);
1114
+ }
1115
+ }
1116
+ })();
1117
+ return () => { cancelled = true; };
1118
+ }, []);
1119
+
1120
+ // Play current track
1121
+ useEffect(() => {
1122
+ if (!track) return;
1123
+
1124
+ const myVersion = ++versionRef.current;
1125
+ const { promise, cancel, pause, resume } = playSoundWithCancel(track.path, { maxSeconds: 0 });
1126
+ cancelRef.current = cancel;
1127
+ pauseRef.current = pause;
1128
+ resumeRef.current = resume;
1129
+ setPlaying(true);
1130
+ setPaused(false);
1131
+ setElapsed(0);
1132
+
1133
+ promise.then(() => {
1134
+ if (versionRef.current === myVersion) {
1135
+ const next = pickRandom(track);
1136
+ if (next) setTrack(next);
1137
+ }
1138
+ }).catch(() => {});
1139
+
1140
+ return () => cancel();
1141
+ }, [track]);
1142
+
1143
+ // Elapsed timer
1144
+ useEffect(() => {
1145
+ if (!playing || paused) return;
1146
+ const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
1147
+ return () => clearInterval(interval);
1148
+ }, [playing, paused]);
1149
+
1150
+ // Controls
1151
+ useInput((input, key) => {
1152
+ if (key.escape) {
1153
+ if (cancelRef.current) cancelRef.current();
1154
+ onBack();
1155
+ } else if (input === "n") {
1156
+ versionRef.current++;
1157
+ if (cancelRef.current) cancelRef.current();
1158
+ const next = pickRandom(track);
1159
+ if (next) setTrack(next);
1160
+ } else if (input === " ") {
1161
+ if (paused) {
1162
+ if (resumeRef.current) resumeRef.current();
1163
+ setPaused(false);
1164
+ } else {
1165
+ if (pauseRef.current) pauseRef.current();
1166
+ setPaused(true);
1167
+ }
1168
+ }
1169
+ });
1170
+
1171
+ // Loading state
1172
+ if (loading) {
1173
+ return h(Box, { flexDirection: "column" },
1174
+ h(Box, { marginLeft: 2, flexDirection: "column", borderStyle: "round", borderColor: ACCENT, paddingX: 2 },
1175
+ h(Text, { bold: true, color: ACCENT }, `🎵 ${gameName || "Music Player"}`),
1176
+ h(Box, { marginTop: 1 },
1177
+ h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1178
+ h(Text, null, ` Scanning for music tracks... ${scanProgress.found} found (${scanProgress.done}/${scanProgress.total})`),
1179
+ ),
1180
+ ),
1181
+ h(NavHint, { back: true }),
1182
+ );
1183
+ }
1184
+
1185
+ if (!track) {
1186
+ return h(Box, { flexDirection: "column" },
1187
+ h(Text, { color: "yellow", marginLeft: 2 }, " No tracks between 30s–10min found."),
1188
+ h(Text, { dimColor: true, marginLeft: 2 }, " Try a different game or source."),
1189
+ h(NavHint, { back: true }),
1190
+ );
1191
+ }
1192
+
1193
+ const trackName = track.displayName || track.name || basename(track.path);
1194
+
1195
+ // Build playlist items — highlight currently playing track
1196
+ const playlistItems = pool.map((t) => {
1197
+ const name = t.displayName || t.name || basename(t.path);
1198
+ const durStr = t.duration ? ` (${formatTime(t.duration)})` : "";
1199
+ const isPlaying = t.path === track.path;
1200
+ return {
1201
+ label: `${isPlaying ? "▶ " : " "}${name}${durStr}`,
1202
+ value: t.path,
1203
+ };
1204
+ });
1205
+
1206
+ return h(Box, { flexDirection: "column" },
1207
+ h(Box, { marginLeft: 2, flexDirection: "column", borderStyle: "round", borderColor: paused ? "yellow" : "green", paddingX: 2 },
1208
+ h(Text, { bold: true, color: paused ? "yellow" : "green" }, `🎵 ${gameName || "Music Player"}`),
1209
+ h(Box, { marginTop: 1 },
1210
+ h(Text, { color: paused ? "yellow" : "green", bold: true },
1211
+ paused ? "⏸ " : "▶ ",
1212
+ ),
1213
+ h(Text, { bold: true }, trackName),
1214
+ ),
1215
+ track.gameName
1216
+ ? h(Text, { dimColor: true }, ` ${track.gameName}`)
1217
+ : null,
1218
+ h(Text, { dimColor: true },
1219
+ ` ${formatTime(elapsed)} / ${formatTime(track.duration || 0)}`,
1220
+ ),
1221
+ ),
1222
+ h(Box, { marginTop: 1, marginLeft: 2 },
1223
+ scanDone
1224
+ ? h(Text, { dimColor: true }, ` ${pool.length} tracks`)
1225
+ : h(Box, null,
1226
+ h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1227
+ h(Text, { dimColor: true }, ` ${pool.length} tracks (${scanProgress.done}/${scanProgress.total} scanned)`),
1228
+ ),
1229
+ ),
1230
+ h(Box, { marginLeft: 2 },
1231
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items: playlistItems, limit: 12,
1232
+ onSelect: (item) => {
1233
+ const picked = poolRef.current.find((t) => t.path === item.value);
1234
+ if (picked) {
1235
+ versionRef.current++;
1236
+ if (cancelRef.current) cancelRef.current();
1237
+ setTrack(picked);
1238
+ }
1239
+ },
1240
+ }),
1241
+ ),
1242
+ h(Box, { marginLeft: 4 },
1243
+ h(Text, { dimColor: true }, "n random space pause esc back"),
1244
+ ),
1245
+ );
1246
+ };
1247
+
1248
+ // ── Screen: Extracting ──────────────────────────────────────────
1249
+ const ExtractingScreen = ({ game, onDone, onBack }) => {
1250
+ const [status, setStatus] = useState("Checking cache...");
1251
+ const [extracted, setExtracted] = useState(0);
1252
+
1253
+ useInput((_, key) => { if (key.escape) onBack(); });
1254
+
1255
+ useEffect(() => {
1256
+ let cancelled = false;
1257
+
1258
+ (async () => {
1259
+ try {
1260
+ // Check cache first
1261
+ const cached = await getCachedExtraction(game.name);
1262
+ if (cached && cached.files.length > 0) {
1263
+ setStatus(`Found ${cached.files.length} cached sounds`);
1264
+ if (!cancelled) {
1265
+ onDone({
1266
+ files: cached.files.map((f) => ({
1267
+ path: f.path,
1268
+ name: f.name,
1269
+ displayName: f.displayName,
1270
+ category: f.category,
1271
+ dir: dirname(f.path),
1272
+ })),
1273
+ categories: cached.categories,
1274
+ fromCache: true,
1275
+ });
1276
+ }
1277
+ return;
1278
+ }
1279
+
1280
+ const outputDir = join(tmpdir(), "klaudio-extract", game.name.replace(/[^a-zA-Z0-9]/g, "_"));
1281
+ const allOutputs = [];
1282
+
1283
+ // Unity .resource files — extract FSB5 banks directly (no vgmstream needed for PCM16)
1284
+ const fsbFiles = []; // Vorbis .fsb files that need vgmstream conversion
1285
+ if (game.unityResources && game.unityResources.length > 0) {
1286
+ setStatus(`Extracting Unity audio from ${game.unityResources.length} resource file(s)...`);
1287
+ for (const resPath of game.unityResources) {
1288
+ if (cancelled) return;
1289
+ setStatus(`Extracting: ${basename(resPath)}`);
1290
+ try {
1291
+ const extracted = await extractUnityResource(resPath, outputDir);
1292
+ for (const f of extracted) {
1293
+ if (f.path.endsWith(".wav")) {
1294
+ allOutputs.push(f.path);
1295
+ } else if (f.path.endsWith(".fsb")) {
1296
+ fsbFiles.push({ path: f.path, name: f.name });
1297
+ }
1298
+ }
1299
+ setExtracted(allOutputs.length);
1300
+ } catch { /* skip */ }
1301
+ }
1302
+ }
1303
+
1304
+ // Scan for packed audio files (Wwise/FMOD/BUN)
1305
+ let packedFiles = [];
1306
+ if (allOutputs.length === 0 || !game.unityResources?.length) {
1307
+ setStatus(`Scanning ${game.name} for packed audio...`);
1308
+ packedFiles = await findPackedAudioFiles(game.path, 30);
1309
+ }
1310
+
1311
+ // Extract BUN files natively (SCUMM engine audio)
1312
+ const bunFiles = packedFiles.filter((f) => f.name.toLowerCase().endsWith(".bun"));
1313
+ const nonBunFiles = packedFiles.filter((f) => !f.name.toLowerCase().endsWith(".bun"));
1314
+
1315
+ for (const file of bunFiles) {
1316
+ if (cancelled) return;
1317
+ setStatus(`Extracting SCUMM audio: ${file.name}`);
1318
+ try {
1319
+ const bunOutputs = await extractBunFile(file.path, outputDir, (msg) => {
1320
+ if (!cancelled) setStatus(msg);
1321
+ });
1322
+ allOutputs.push(...bunOutputs);
1323
+ setExtracted(allOutputs.length);
1324
+ } catch { /* skip */ }
1325
+ }
1326
+
1327
+ // Convert extracted .fsb Vorbis files via vgmstream, or handle non-BUN packed audio
1328
+ const needsVgmstream = fsbFiles.length > 0 || nonBunFiles.length > 0;
1329
+ if (needsVgmstream) {
1330
+ // Get vgmstream-cli (downloads if needed)
1331
+ setStatus("Getting vgmstream-cli...");
1332
+ const vgmstream = await getVgmstreamPath((msg) => {
1333
+ if (!cancelled) setStatus(msg);
1334
+ });
1335
+
1336
+ // Convert Unity-extracted .fsb files
1337
+ for (const fsb of fsbFiles) {
1338
+ if (cancelled) return;
1339
+ setStatus(`Converting: ${fsb.name}`);
1340
+ try {
1341
+ const outputs = await extractToWav(fsb.path, outputDir, vgmstream);
1342
+ allOutputs.push(...outputs);
1343
+ setExtracted(allOutputs.length);
1344
+ } catch { /* skip */ }
1345
+ }
1346
+
1347
+ // Convert non-BUN packed audio via vgmstream
1348
+ for (const file of nonBunFiles) {
1349
+ if (cancelled) return;
1350
+ setStatus(`Extracting: ${file.name}`);
1351
+ try {
1352
+ const outputs = await extractToWav(file.path, outputDir, vgmstream);
1353
+ allOutputs.push(...outputs);
1354
+ setExtracted(allOutputs.length);
1355
+ } catch { /* skip */ }
1356
+ }
1357
+ }
1358
+
1359
+ if (allOutputs.length === 0 && fsbFiles.length === 0 && packedFiles.length === 0) {
1360
+ if (!cancelled) onDone({ files: [], error: "No extractable audio files found" });
1361
+ return;
1362
+ }
1363
+
1364
+ if (!cancelled) {
1365
+ // Cache the results with category metadata
1366
+ const rawFiles = allOutputs.map((p) => ({ path: p, name: basename(p) }));
1367
+ setStatus("Caching extracted sounds...");
1368
+ const manifest = await cacheExtraction(game.name, rawFiles, game.path);
1369
+
1370
+ onDone({
1371
+ files: manifest.files.map((f) => ({
1372
+ path: f.path,
1373
+ name: f.name,
1374
+ displayName: f.displayName,
1375
+ category: f.category,
1376
+ dir: dirname(f.path),
1377
+ })),
1378
+ categories: manifest.categories,
1379
+ });
1380
+ }
1381
+ } catch (err) {
1382
+ if (!cancelled) onDone({ files: [], error: err.message });
1383
+ }
1384
+ })();
1385
+
1386
+ return () => { cancelled = true; };
1387
+ }, []);
1388
+
1389
+ return h(Box, { flexDirection: "column" },
1390
+ h(Box, { marginLeft: 2, flexDirection: "column", borderStyle: "round", borderColor: ACCENT, paddingX: 2 },
1391
+ h(Text, { bold: true, color: ACCENT }, `Extracting audio from ${game.name}`),
1392
+ h(Box, { marginTop: 1 },
1393
+ h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1394
+ h(Text, null, ` ${status}`),
1395
+ ),
1396
+ extracted > 0
1397
+ ? h(Text, { color: "green" }, ` ${extracted} sound(s) extracted so far`)
1398
+ : null,
1399
+ ),
1400
+ h(NavHint, { back: true }),
1401
+ );
1402
+ };
1403
+
1404
+ // ── Screen: Confirm ─────────────────────────────────────────────
1405
+ const ConfirmScreen = ({ scope, sounds, tts, voice, hasKokoro, onToggleTts, onCycleVoice, onConfirm, onBack }) => {
1406
+ useInput((input, key) => {
1407
+ if (key.escape) onBack();
1408
+ else if (input === "t") onToggleTts();
1409
+ else if (input === "v" && tts && hasKokoro) onCycleVoice();
1410
+ });
1411
+
1412
+ const items = [
1413
+ { label: "✓ Yes, install!", value: "yes" },
1414
+ { label: "✗ No, go back", value: "no" },
1415
+ ];
1416
+
1417
+ const soundEntries = Object.entries(sounds).filter(([_, path]) => path);
1418
+ const voiceInfo = hasKokoro ? KOKORO_VOICES.find((v) => v.id === voice) : null;
1419
+
1420
+ return h(Box, { flexDirection: "column" },
1421
+ h(Text, { bold: true, marginLeft: 2 }, " Ready to install:"),
1422
+ h(Box, { marginTop: 1, flexDirection: "column" },
1423
+ h(Text, { marginLeft: 4 }, `Scope: ${scope === "global" ? "Global (Claude Code + Copilot)" : "This project (Claude Code + Copilot)"}`),
1424
+ ...soundEntries.map(([eid, path]) =>
1425
+ h(Text, { key: eid, marginLeft: 4 },
1426
+ `${EVENTS[eid].name} → ${shortName(path)}`
1427
+ )
1428
+ ),
1429
+ h(Box, { marginLeft: 4, marginTop: 1 },
1430
+ h(Text, { color: tts ? "green" : "gray" },
1431
+ tts ? "🗣 Voice summary: ON" : "🗣 Voice summary: OFF",
1432
+ ),
1433
+ h(Text, { dimColor: true }, " (t to toggle — reads a short summary when tasks complete)"),
1434
+ ),
1435
+ tts && voiceInfo ? h(Box, { marginLeft: 4 },
1436
+ h(Text, { color: ACCENT },
1437
+ `🎙 Voice: ${voiceInfo.name} (${voiceInfo.gender}, ${voiceInfo.accent})`,
1438
+ ),
1439
+ h(Text, { dimColor: true }, " (v to change voice)"),
1440
+ ) : null,
1441
+ ),
1442
+ h(Box, { marginTop: 1, marginLeft: 2 },
1443
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, onSelect: (item) => {
1444
+ if (item.value === "yes") onConfirm();
1445
+ else onBack();
1446
+ }}),
1447
+ ),
1448
+ h(NavHint, { back: true }),
1449
+ );
1450
+ };
1451
+
1452
+ // ── Screen: Installing ──────────────────────────────────────────
1453
+ const InstallingScreen = ({ scope, sounds, tts, voice, onDone }) => {
1454
+ useEffect(() => {
1455
+ const validSounds = {};
1456
+ for (const [eventId, path] of Object.entries(sounds)) {
1457
+ if (path) validSounds[eventId] = path;
1458
+ }
1459
+ install({ scope, sounds: validSounds, tts, voice }).then(onDone).catch((err) => {
1460
+ onDone({ error: err.message });
1461
+ });
1462
+ }, []);
1463
+
1464
+ return h(Box, { marginLeft: 2 },
1465
+ h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1466
+ h(Text, null, " Installing sounds..."),
1467
+ );
1468
+ };
1469
+
1470
+ // ── Screen: Done ────────────────────────────────────────────────
1471
+ const DoneScreen = ({ result }) => {
1472
+ const { exit } = useApp();
1473
+
1474
+ useEffect(() => {
1475
+ // Play the "stop" sound as a demo if it was installed
1476
+ if (result.installedSounds?.stop) {
1477
+ playSoundWithCancel(result.installedSounds.stop).promise.catch(() => {});
1478
+ }
1479
+ const timer = setTimeout(() => exit(), 1500);
1480
+ return () => clearTimeout(timer);
1481
+ }, []);
1482
+
1483
+ if (result.error) {
1484
+ return h(Box, { flexDirection: "column", marginLeft: 2 },
1485
+ h(Text, { color: "red", bold: true }, " ✗ Installation failed:"),
1486
+ h(Text, { color: "red" }, ` ${result.error}`),
1487
+ );
1488
+ }
1489
+
1490
+ return h(Box, { flexDirection: "column", marginLeft: 2 },
1491
+ h(Text, { color: "green", bold: true }, " ✓ Sounds installed!"),
1492
+ h(Box, { marginTop: 1, flexDirection: "column" },
1493
+ h(Text, null, ` Sound files: ${result.soundsDir}`),
1494
+ h(Text, null, ` Config: ${result.settingsFile}`),
1495
+ ),
1496
+ h(Box, { marginTop: 1, flexDirection: "column" },
1497
+ h(Text, null, " Your Claude Code sessions will now play sounds for:"),
1498
+ ...Object.keys(result.installedSounds).map((eventId) =>
1499
+ h(Text, { key: eventId, color: "green" }, ` • ${EVENTS[eventId].name}`)
1500
+ ),
1501
+ ),
1502
+ h(Box, { marginTop: 1 },
1503
+ h(Text, { dimColor: true }, " To remove: npx klaudio --uninstall"),
1504
+ ),
1505
+ );
1506
+ };
1507
+
1508
+ // ── Uninstall App ───────────────────────────────────────────────
1509
+ const UninstallApp = () => {
1510
+ const { exit } = useApp();
1511
+ const [phase, setPhase] = useState("scope"); // scope | working | done | notfound
1512
+ const [scope, setScope] = useState(null);
1513
+
1514
+ useEffect(() => {
1515
+ if (phase === "working" && scope) {
1516
+ uninstall(scope).then((ok) => {
1517
+ setPhase(ok ? "done" : "notfound");
1518
+ setTimeout(() => exit(), 500);
1519
+ });
1520
+ }
1521
+ }, [phase, scope]);
1522
+
1523
+ if (phase === "scope") {
1524
+ return h(Box, { flexDirection: "column" },
1525
+ h(Header, null),
1526
+ h(ScopeScreen, {
1527
+ onNext: (s) => { setScope(s); setPhase("working"); },
1528
+ }),
1529
+ );
1530
+ }
1531
+
1532
+ if (phase === "working") {
1533
+ return h(Box, { flexDirection: "column" },
1534
+ h(Header, null),
1535
+ h(Box, { marginLeft: 2 },
1536
+ h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1537
+ h(Text, null, " Removing sounds..."),
1538
+ ),
1539
+ );
1540
+ }
1541
+
1542
+ if (phase === "done") {
1543
+ return h(Box, { flexDirection: "column" },
1544
+ h(Header, null),
1545
+ h(Text, { color: "green", marginLeft: 2 }, " ✓ Klaudio hooks removed."),
1546
+ );
1547
+ }
1548
+
1549
+ return h(Box, { flexDirection: "column" },
1550
+ h(Header, null),
1551
+ h(Text, { color: "yellow", marginLeft: 2 }, " No Klaudio configuration found."),
1552
+ );
1553
+ };
1554
+
1555
+ // ── Main Install App ────────────────────────────────────────────
1556
+ const InstallApp = () => {
1557
+ const [screen, setScreen] = useState(SCREEN.SCOPE);
1558
+ const [scope, setScope] = useState(null);
1559
+ const [presetId, setPresetId] = useState(null);
1560
+ const [sounds, setSounds] = useState({});
1561
+ const [selectedGame, setSelectedGame] = useState(null);
1562
+ const [installResult, setInstallResult] = useState(null);
1563
+ const [tts, setTts] = useState(true);
1564
+ const [voice, setVoice] = useState(KOKORO_DEFAULT_VOICE);
1565
+ const [hasKokoro, setHasKokoro] = useState(false);
1566
+ const [outdatedReasons, setOutdatedReasons] = useState([]);
1567
+ const [musicFiles, setMusicFiles] = useState([]);
1568
+ const [musicGameName, setMusicGameName] = useState(null);
1569
+ const [musicShuffle, setMusicShuffle] = useState(false);
1570
+
1571
+ useEffect(() => {
1572
+ isKokoroAvailable().then(setHasKokoro).catch(() => {});
1573
+ // Check both scopes for outdated hooks on startup
1574
+ Promise.all([
1575
+ checkHooksOutdated("global"),
1576
+ checkHooksOutdated("project"),
1577
+ ]).then(([g, p]) => {
1578
+ const combined = [...new Set([...g, ...p])];
1579
+ setOutdatedReasons(combined);
1580
+ }).catch(() => {});
1581
+ // Also pre-load existing sounds from global (most common)
1582
+ getExistingSounds("global").then((existing) => {
1583
+ if (Object.keys(existing).length > 0) setSounds(existing);
1584
+ }).catch(() => {});
1585
+ }, []);
1586
+
1587
+ const initSoundsFromPreset = useCallback((pid) => {
1588
+ const preset = PRESETS[pid];
1589
+ if (preset) setSounds({ ...preset.sounds });
1590
+ }, []);
1591
+
1592
+ const content = (() => {
1593
+ switch (screen) {
1594
+ case SCREEN.SCOPE:
1595
+ return h(ScopeScreen, {
1596
+ tts,
1597
+ outdatedReasons,
1598
+ onToggleTts: () => setTts((v) => !v),
1599
+ onNext: (s) => {
1600
+ setScope(s);
1601
+ // Refresh sounds/outdated for the selected scope
1602
+ getExistingSounds(s).then((existing) => {
1603
+ if (Object.keys(existing).length > 0) setSounds(existing);
1604
+ });
1605
+ checkHooksOutdated(s).then(setOutdatedReasons).catch(() => {});
1606
+ setScreen(SCREEN.PRESET);
1607
+ },
1608
+ onUpdate: () => {
1609
+ // Quick-apply: use global scope with existing sounds, skip to install
1610
+ setScope("global");
1611
+ setScreen(SCREEN.INSTALLING);
1612
+ },
1613
+ onMusic: () => setScreen(SCREEN.MUSIC_MODE),
1614
+ });
1615
+
1616
+ case SCREEN.PRESET:
1617
+ return h(PresetScreen, {
1618
+ existingSounds: sounds,
1619
+ outdatedReasons,
1620
+ onReapply: () => setScreen(SCREEN.CONFIRM),
1621
+ onNext: (id) => {
1622
+ if (id === "_music") {
1623
+ setScreen(SCREEN.MUSIC_MODE);
1624
+ } else if (id === "_system") {
1625
+ getSystemSounds().then((files) => {
1626
+ const catFiles = categorizeLooseFiles(files);
1627
+ setSelectedGame({ name: "System Sounds", path: "", files: catFiles, fileCount: catFiles.length, hasAudio: catFiles.length > 0 });
1628
+ setScreen(SCREEN.GAME_SOUNDS);
1629
+ });
1630
+ } else if (id === "_scan") {
1631
+ setScreen(SCREEN.GAME_PICK);
1632
+ } else if (id === "_custom") {
1633
+ const firstPreset = Object.keys(PRESETS)[0];
1634
+ setPresetId(firstPreset);
1635
+ initSoundsFromPreset(firstPreset);
1636
+ setScreen(SCREEN.PREVIEW);
1637
+ } else {
1638
+ setPresetId(id);
1639
+ initSoundsFromPreset(id);
1640
+ if (KOKORO_PRESET_VOICES[id]) setVoice(KOKORO_PRESET_VOICES[id]);
1641
+ setScreen(SCREEN.PREVIEW);
1642
+ }
1643
+ },
1644
+ onBack: () => setScreen(SCREEN.SCOPE),
1645
+ });
1646
+
1647
+ case SCREEN.PREVIEW:
1648
+ return h(PreviewScreen, {
1649
+ presetId,
1650
+ sounds,
1651
+ onAccept: (finalSounds) => {
1652
+ setSounds(finalSounds);
1653
+ setScreen(SCREEN.CONFIRM);
1654
+ },
1655
+ onBack: () => setScreen(SCREEN.PRESET),
1656
+ onUpdateSound: (eventId, path) => {
1657
+ setSounds((prev) => {
1658
+ const next = { ...prev };
1659
+ if (path === null) delete next[eventId];
1660
+ else next[eventId] = path;
1661
+ return next;
1662
+ });
1663
+ },
1664
+ });
1665
+
1666
+ case SCREEN.GAME_PICK:
1667
+ return h(GamePickScreen, {
1668
+ onNext: (gameName, gamesList) => {
1669
+ const game = gamesList.find((g) => g.name === gameName);
1670
+ const catFiles = categorizeLooseFiles(game.files);
1671
+ setSelectedGame({ ...game, files: catFiles });
1672
+ setScreen(SCREEN.GAME_SOUNDS);
1673
+ },
1674
+ onExtract: (gameName, gamesList) => {
1675
+ const game = gamesList.find((g) => g.name === gameName);
1676
+ setSelectedGame(game);
1677
+ setScreen(SCREEN.EXTRACTING);
1678
+ },
1679
+ onBack: () => setScreen(SCREEN.PRESET),
1680
+ });
1681
+
1682
+ case SCREEN.GAME_SOUNDS:
1683
+ return h(GameSoundsScreen, {
1684
+ game: selectedGame,
1685
+ sounds,
1686
+ onSelectSound: (eventId, path) => {
1687
+ setSounds((prev) => ({ ...prev, [eventId]: path }));
1688
+ },
1689
+ onDone: () => {
1690
+ setScreen(SCREEN.CONFIRM);
1691
+ },
1692
+ onBack: () => setScreen(SCREEN.GAME_PICK),
1693
+ });
1694
+
1695
+ case SCREEN.EXTRACTING:
1696
+ return h(ExtractingScreen, {
1697
+ game: selectedGame,
1698
+ onDone: (result) => {
1699
+ if (result.error || result.files.length === 0) {
1700
+ // Go back to game pick — extraction failed
1701
+ setScreen(SCREEN.GAME_PICK);
1702
+ } else {
1703
+ // Update the selected game with extracted files and go to sound picker
1704
+ setSelectedGame({
1705
+ ...selectedGame,
1706
+ files: result.files,
1707
+ fileCount: result.files.length,
1708
+ hasAudio: true,
1709
+ });
1710
+ setScreen(SCREEN.GAME_SOUNDS);
1711
+ }
1712
+ },
1713
+ onBack: () => setScreen(SCREEN.GAME_PICK),
1714
+ });
1715
+
1716
+ case SCREEN.CONFIRM:
1717
+ return h(ConfirmScreen, {
1718
+ scope,
1719
+ sounds,
1720
+ tts,
1721
+ voice,
1722
+ hasKokoro,
1723
+ onToggleTts: () => setTts((v) => !v),
1724
+ onCycleVoice: () => setVoice((v) => {
1725
+ const idx = KOKORO_VOICES.findIndex((x) => x.id === v);
1726
+ return KOKORO_VOICES[(idx + 1) % KOKORO_VOICES.length].id;
1727
+ }),
1728
+ onConfirm: () => setScreen(SCREEN.INSTALLING),
1729
+ onBack: () => {
1730
+ if (selectedGame) setScreen(SCREEN.GAME_SOUNDS);
1731
+ else setScreen(SCREEN.PREVIEW);
1732
+ },
1733
+ });
1734
+
1735
+ case SCREEN.INSTALLING:
1736
+ return h(InstallingScreen, {
1737
+ scope,
1738
+ sounds,
1739
+ tts,
1740
+ voice,
1741
+ onDone: (result) => {
1742
+ setInstallResult(result);
1743
+ setScreen(SCREEN.DONE);
1744
+ },
1745
+ });
1746
+
1747
+ case SCREEN.DONE:
1748
+ return h(DoneScreen, { result: installResult });
1749
+
1750
+ case SCREEN.MUSIC_MODE:
1751
+ return h(MusicModeScreen, {
1752
+ onRandom: () => {
1753
+ listCachedGames().then((games) => {
1754
+ const allFiles = games.flatMap((g) => g.files.map((f) => ({ ...f, gameName: g.gameName })));
1755
+ setMusicFiles(allFiles);
1756
+ setMusicGameName("All Games");
1757
+ setMusicShuffle(true);
1758
+ setScreen(SCREEN.MUSIC_PLAYING);
1759
+ });
1760
+ },
1761
+ onPickGame: () => setScreen(SCREEN.MUSIC_GAME_PICK),
1762
+ onBack: () => setScreen(SCREEN.SCOPE),
1763
+ });
1764
+
1765
+ case SCREEN.MUSIC_GAME_PICK:
1766
+ return h(MusicGamePickScreen, {
1767
+ onNext: (game) => {
1768
+ setMusicFiles(game.files.map((f) => ({ ...f, gameName: game.name })));
1769
+ setMusicGameName(game.name);
1770
+ setMusicShuffle(false);
1771
+ setScreen(SCREEN.MUSIC_PLAYING);
1772
+ },
1773
+ onExtract: (game) => {
1774
+ setSelectedGame(game);
1775
+ setScreen(SCREEN.MUSIC_EXTRACTING);
1776
+ },
1777
+ onBack: () => setScreen(SCREEN.MUSIC_MODE),
1778
+ });
1779
+
1780
+ case SCREEN.MUSIC_PLAYING:
1781
+ return h(MusicPlayingScreen, {
1782
+ files: musicFiles,
1783
+ gameName: musicGameName,
1784
+ shuffle: musicShuffle,
1785
+ onBack: () => setScreen(SCREEN.MUSIC_MODE),
1786
+ });
1787
+
1788
+ case SCREEN.MUSIC_EXTRACTING:
1789
+ return h(ExtractingScreen, {
1790
+ game: selectedGame,
1791
+ onDone: (result) => {
1792
+ if (result.error || result.files.length === 0) {
1793
+ setScreen(SCREEN.MUSIC_GAME_PICK);
1794
+ } else {
1795
+ // Go straight to playing the extracted files
1796
+ setMusicFiles(result.files.map((f) => ({ ...f, gameName: selectedGame.name })));
1797
+ setMusicGameName(selectedGame.name);
1798
+ setMusicShuffle(true);
1799
+ setScreen(SCREEN.MUSIC_PLAYING);
1800
+ }
1801
+ },
1802
+ onBack: () => setScreen(SCREEN.MUSIC_GAME_PICK),
1803
+ });
1804
+
1805
+ default:
1806
+ return h(Text, { color: "red" }, "Unknown screen");
1807
+ }
1808
+ })();
1809
+
1810
+ return h(Box, { flexDirection: "column" },
1811
+ h(Header, null),
1812
+ content,
1813
+ );
1814
+ };
1815
+
1816
+ // ── Entry ───────────────────────────────────────────────────────
1817
+ export async function run() {
1818
+ const AppComponent = isUninstallMode ? UninstallApp : InstallApp;
1819
+ const instance = render(h(AppComponent));
1820
+ await instance.waitUntilExit();
1821
+ }