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/README.md +96 -96
- package/bin/cli.js +44 -44
- package/package.json +40 -44
- package/src/cache.js +306 -306
- package/src/cli.js +1821 -1821
- package/src/extractor.js +213 -213
- package/src/installer.js +369 -368
- package/src/notify.js +138 -135
- package/src/player.js +488 -488
- package/src/presets.js +87 -87
- package/src/scanner.js +445 -445
- package/src/scumm.js +560 -560
- package/src/tts.js +391 -391
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
|
+
}
|