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