klaudio 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/package.json +1 -1
- package/src/cache.js +27 -0
- package/src/cli.js +342 -14
- package/src/player.js +41 -22
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ npx klaudio
|
|
|
10
10
|
|
|
11
11
|
The interactive installer walks you through:
|
|
12
12
|
|
|
13
|
-
1. **Choose scope** — install globally (`~/.claude`) or per-project (`.claude/`)
|
|
13
|
+
1. **Choose scope** — install globally (`~/.claude`) or per-project (`.claude/`), or launch the **Music Player**
|
|
14
14
|
2. **Pick a source** — use a built-in preset, scan your Steam & Epic Games library for sounds, or provide custom files
|
|
15
15
|
3. **Preview & assign** — listen to sounds and assign them to events (tab to switch between events)
|
|
16
16
|
4. **Install** — writes Claude Code hooks to your `settings.json`
|
|
@@ -36,6 +36,17 @@ Scans your local Steam and Epic Games libraries for audio files:
|
|
|
36
36
|
|
|
37
37
|
Point to your own `.wav`/`.mp3` files.
|
|
38
38
|
|
|
39
|
+
## Music Player
|
|
40
|
+
|
|
41
|
+
Play longer game tracks (90s–4min) as background music while you code:
|
|
42
|
+
|
|
43
|
+
- **Shuffle all** — scans all cached game audio, filters by duration, picks random tracks continuously
|
|
44
|
+
- **Play songs from game** — pick a specific cached game and play its music
|
|
45
|
+
- Controls: `n` next, `space` pause/resume, `esc` back
|
|
46
|
+
- Background scanning — starts playing as soon as the first track is found, keeps indexing
|
|
47
|
+
|
|
48
|
+
Requires previously extracted game audio (use "Scan local games" first).
|
|
49
|
+
|
|
39
50
|
## Features
|
|
40
51
|
|
|
41
52
|
- **Auto-preview** — sounds play automatically as you browse the list (toggle with `p`)
|
package/package.json
CHANGED
package/src/cache.js
CHANGED
|
@@ -153,6 +153,33 @@ function gameCacheDir(gameName) {
|
|
|
153
153
|
return join(CACHE_DIR, gameName.replace(/[^a-zA-Z0-9_-]/g, "_"));
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* List all cached games with their files.
|
|
158
|
+
* Returns array of { gameName, files[] }.
|
|
159
|
+
*/
|
|
160
|
+
export async function listCachedGames() {
|
|
161
|
+
const games = [];
|
|
162
|
+
try {
|
|
163
|
+
const entries = await readdir(CACHE_DIR, { withFileTypes: true });
|
|
164
|
+
for (const entry of entries) {
|
|
165
|
+
if (!entry.isDirectory()) continue;
|
|
166
|
+
try {
|
|
167
|
+
const manifest = JSON.parse(
|
|
168
|
+
await readFile(join(CACHE_DIR, entry.name, "manifest.json"), "utf-8")
|
|
169
|
+
);
|
|
170
|
+
if (manifest.files?.length > 0) {
|
|
171
|
+
// Verify at least one file still exists
|
|
172
|
+
try {
|
|
173
|
+
await stat(join(CACHE_DIR, entry.name, manifest.files[0].name));
|
|
174
|
+
games.push({ gameName: manifest.gameName, files: manifest.files });
|
|
175
|
+
} catch { /* files cleaned up, skip */ }
|
|
176
|
+
}
|
|
177
|
+
} catch { /* no manifest, skip */ }
|
|
178
|
+
}
|
|
179
|
+
} catch { /* cache dir doesn't exist */ }
|
|
180
|
+
return games;
|
|
181
|
+
}
|
|
182
|
+
|
|
156
183
|
/**
|
|
157
184
|
* Check if we have a cached extraction for a game.
|
|
158
185
|
* Returns the manifest if cached, null otherwise.
|
package/src/cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
2
2
|
import { render, Box, Text, useInput, useApp } from "ink";
|
|
3
3
|
import Spinner from "ink-spinner";
|
|
4
4
|
import { PRESETS, EVENTS } from "./presets.js";
|
|
@@ -8,7 +8,7 @@ import { install, uninstall, getExistingSounds } from "./installer.js";
|
|
|
8
8
|
import { getVgmstreamPath, findPackedAudioFiles, extractToWav } from "./extractor.js";
|
|
9
9
|
import { extractUnityResource } from "./unity.js";
|
|
10
10
|
import { extractBunFile, isBunFile } from "./scumm.js";
|
|
11
|
-
import { getCachedExtraction, cacheExtraction, categorizeLooseFiles, getCategories, sortFilesByPriority } from "./cache.js";
|
|
11
|
+
import { getCachedExtraction, cacheExtraction, categorizeLooseFiles, getCategories, sortFilesByPriority, listCachedGames } from "./cache.js";
|
|
12
12
|
import { basename, dirname } from "node:path";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
@@ -90,6 +90,9 @@ const SCREEN = {
|
|
|
90
90
|
CONFIRM: 7,
|
|
91
91
|
INSTALLING: 8,
|
|
92
92
|
DONE: 9,
|
|
93
|
+
MUSIC_MODE: 10,
|
|
94
|
+
MUSIC_GAME_PICK: 11,
|
|
95
|
+
MUSIC_PLAYING: 12,
|
|
93
96
|
};
|
|
94
97
|
|
|
95
98
|
const isUninstallMode = process.argv.includes("--uninstall") || process.argv.includes("--remove");
|
|
@@ -112,15 +115,38 @@ const NavHint = ({ back = true, extra = "" }) =>
|
|
|
112
115
|
);
|
|
113
116
|
|
|
114
117
|
// ── Screen: Scope ───────────────────────────────────────────────
|
|
115
|
-
const ScopeScreen = ({ onNext }) => {
|
|
118
|
+
const ScopeScreen = ({ onNext, onMusic }) => {
|
|
116
119
|
const items = [
|
|
117
|
-
{ label: "Global — Claude Code +
|
|
118
|
-
{ label: "This project — Claude Code + Copilot (
|
|
120
|
+
{ label: "Global — Claude Code + Copilot (all projects)", value: "global" },
|
|
121
|
+
{ label: "This project — Claude Code + Copilot (this project only)", value: "project" },
|
|
122
|
+
// index 2 = music
|
|
123
|
+
{ label: "🎵 Play game music while you code", value: "_music" },
|
|
119
124
|
];
|
|
125
|
+
const [sel, setSel] = useState(0);
|
|
126
|
+
const GAP_AT = 2; // visual gap before this index
|
|
127
|
+
|
|
128
|
+
useInput((input, key) => {
|
|
129
|
+
if (input === "k" || key.upArrow) {
|
|
130
|
+
setSel((i) => Math.max(0, i - 1));
|
|
131
|
+
} else if (input === "j" || key.downArrow) {
|
|
132
|
+
setSel((i) => Math.min(items.length - 1, i + 1));
|
|
133
|
+
} else if (key.return) {
|
|
134
|
+
const v = items[sel].value;
|
|
135
|
+
if (v === "_music") onMusic();
|
|
136
|
+
else onNext(v);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
120
140
|
return h(Box, { flexDirection: "column" },
|
|
121
141
|
h(Text, { bold: true }, " Where should sounds be installed?"),
|
|
122
|
-
h(Box, { marginLeft: 2 },
|
|
123
|
-
|
|
142
|
+
h(Box, { flexDirection: "column", marginLeft: 2 },
|
|
143
|
+
...items.map((item, i) => h(React.Fragment, { key: item.value },
|
|
144
|
+
i === GAP_AT ? h(Text, { dimColor: true }, "\n ...or") : null,
|
|
145
|
+
h(Box, null,
|
|
146
|
+
h(Indicator, { isSelected: i === sel }),
|
|
147
|
+
h(Item, { isSelected: i === sel, label: item.label }),
|
|
148
|
+
),
|
|
149
|
+
)),
|
|
124
150
|
),
|
|
125
151
|
);
|
|
126
152
|
};
|
|
@@ -450,13 +476,13 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
|
|
|
450
476
|
const meaningfulCats = categories.filter((c) => c !== "all" && (counts[c] || 0) >= 2);
|
|
451
477
|
const showCategoryPicker = meaningfulCats.length >= 2;
|
|
452
478
|
|
|
453
|
-
// Sort files: voice first, then by priority
|
|
454
|
-
const sortedFiles = hasCategories ? sortFilesByPriority(game.files) : game.files;
|
|
479
|
+
// Sort files: voice first, then by priority (memoized for stable references)
|
|
480
|
+
const sortedFiles = useMemo(() => hasCategories ? sortFilesByPriority(game.files) : game.files, [game.files, hasCategories]);
|
|
455
481
|
|
|
456
|
-
// Filter files by category (
|
|
457
|
-
const categoryFiles = activeCategory && activeCategory !== "all"
|
|
482
|
+
// Filter files by category (memoized to prevent infinite re-render loops)
|
|
483
|
+
const categoryFiles = useMemo(() => activeCategory && activeCategory !== "all"
|
|
458
484
|
? sortedFiles.filter((f) => f.category === activeCategory)
|
|
459
|
-
: sortedFiles;
|
|
485
|
+
: sortedFiles, [sortedFiles, activeCategory]);
|
|
460
486
|
|
|
461
487
|
// Stop current playback helper
|
|
462
488
|
const stopPlayback = useCallback(() => {
|
|
@@ -803,6 +829,268 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
|
|
|
803
829
|
);
|
|
804
830
|
};
|
|
805
831
|
|
|
832
|
+
// ── Helpers for Music Player ─────────────────────────────────────
|
|
833
|
+
const formatTime = (secs) => {
|
|
834
|
+
const m = Math.floor(secs / 60);
|
|
835
|
+
const s = Math.floor(secs % 60);
|
|
836
|
+
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
// ── Screen: Music Mode ──────────────────────────────────────────
|
|
840
|
+
const MusicModeScreen = ({ onRandom, onPickGame, onBack }) => {
|
|
841
|
+
useInput((_, key) => { if (key.escape) onBack(); });
|
|
842
|
+
|
|
843
|
+
const items = [
|
|
844
|
+
{ label: "🎲 Shuffle all — play random songs from all cached games", value: "random" },
|
|
845
|
+
{ label: "🎮 Play songs from game — choose a game", value: "game" },
|
|
846
|
+
];
|
|
847
|
+
|
|
848
|
+
return h(Box, { flexDirection: "column" },
|
|
849
|
+
h(Text, { bold: true, marginLeft: 2 }, " 🎵 Music Player"),
|
|
850
|
+
h(Text, { dimColor: true, marginLeft: 2 }, " Play longer game tracks as background music"),
|
|
851
|
+
h(Box, { marginTop: 1, marginLeft: 2 },
|
|
852
|
+
h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items,
|
|
853
|
+
onSelect: (item) => {
|
|
854
|
+
if (item.value === "random") onRandom();
|
|
855
|
+
else onPickGame();
|
|
856
|
+
},
|
|
857
|
+
}),
|
|
858
|
+
),
|
|
859
|
+
h(NavHint, { back: true }),
|
|
860
|
+
);
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
// ── Screen: Music Game Pick ─────────────────────────────────────
|
|
864
|
+
const MusicGamePickScreen = ({ onNext, onBack }) => {
|
|
865
|
+
const [games, setGames] = useState([]);
|
|
866
|
+
const [loading, setLoading] = useState(true);
|
|
867
|
+
|
|
868
|
+
useInput((_, key) => { if (key.escape) onBack(); });
|
|
869
|
+
|
|
870
|
+
useEffect(() => {
|
|
871
|
+
let cancelled = false;
|
|
872
|
+
listCachedGames().then((cached) => {
|
|
873
|
+
if (!cancelled) {
|
|
874
|
+
setGames(cached);
|
|
875
|
+
setLoading(false);
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
return () => { cancelled = true; };
|
|
879
|
+
}, []);
|
|
880
|
+
|
|
881
|
+
if (loading) {
|
|
882
|
+
return h(Box, { flexDirection: "column" },
|
|
883
|
+
h(Box, { marginLeft: 2 },
|
|
884
|
+
h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
|
|
885
|
+
h(Text, null, " Scanning cached games..."),
|
|
886
|
+
),
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (games.length === 0) {
|
|
891
|
+
return h(Box, { flexDirection: "column" },
|
|
892
|
+
h(Text, { color: "yellow", marginLeft: 2 }, " No cached games found."),
|
|
893
|
+
h(Text, { dimColor: true, marginLeft: 2 }, " Use \"Scan local games\" first to extract game audio."),
|
|
894
|
+
h(NavHint, { back: true }),
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const items = games.map((g) => ({
|
|
899
|
+
label: `${g.gameName} (${g.files.length} files)`,
|
|
900
|
+
value: g.gameName,
|
|
901
|
+
}));
|
|
902
|
+
|
|
903
|
+
return h(Box, { flexDirection: "column" },
|
|
904
|
+
h(Text, { bold: true, marginLeft: 2 }, " Pick a game:"),
|
|
905
|
+
h(Box, { marginLeft: 2 },
|
|
906
|
+
h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, limit: 15,
|
|
907
|
+
onSelect: (item) => {
|
|
908
|
+
const game = games.find((g) => g.gameName === item.value);
|
|
909
|
+
onNext(game);
|
|
910
|
+
},
|
|
911
|
+
}),
|
|
912
|
+
),
|
|
913
|
+
h(NavHint, { back: true }),
|
|
914
|
+
);
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
// ── Screen: Music Playing ───────────────────────────────────────
|
|
918
|
+
const MusicPlayingScreen = ({ files, gameName, shuffle: initialShuffle, onBack }) => {
|
|
919
|
+
const [track, setTrack] = useState(null); // current track { path, name, displayName, duration }
|
|
920
|
+
const [loading, setLoading] = useState(true);
|
|
921
|
+
const [scanProgress, setScanProgress] = useState({ done: 0, total: files.length, found: 0 });
|
|
922
|
+
const [scanDone, setScanDone] = useState(false);
|
|
923
|
+
const [playing, setPlaying] = useState(false);
|
|
924
|
+
const [paused, setPaused] = useState(false);
|
|
925
|
+
const [elapsed, setElapsed] = useState(0);
|
|
926
|
+
const [poolSize, setPoolSize] = useState(0);
|
|
927
|
+
const cancelRef = useRef(null);
|
|
928
|
+
const pauseRef = useRef(null);
|
|
929
|
+
const resumeRef = useRef(null);
|
|
930
|
+
const versionRef = useRef(0);
|
|
931
|
+
const poolRef = useRef([]); // ever-growing pool of qualifying tracks
|
|
932
|
+
const lastPlayedRef = useRef(-1); // index of last played track in pool
|
|
933
|
+
|
|
934
|
+
// Pick a random track from the pool (different from last played)
|
|
935
|
+
const pickFromPool = useCallback(() => {
|
|
936
|
+
const pool = poolRef.current;
|
|
937
|
+
if (pool.length === 0) return null;
|
|
938
|
+
if (pool.length === 1) return pool[0];
|
|
939
|
+
let idx;
|
|
940
|
+
do { idx = Math.floor(Math.random() * pool.length); } while (idx === lastPlayedRef.current && pool.length > 1);
|
|
941
|
+
lastPlayedRef.current = idx;
|
|
942
|
+
return pool[idx];
|
|
943
|
+
}, []);
|
|
944
|
+
|
|
945
|
+
// Scan files for duration (90s-4min), pick first random once found, keep scanning in background
|
|
946
|
+
const startedRef = useRef(false);
|
|
947
|
+
useEffect(() => {
|
|
948
|
+
let cancelled = false;
|
|
949
|
+
(async () => {
|
|
950
|
+
const BATCH = 20;
|
|
951
|
+
for (let i = 0; i < files.length; i += BATCH) {
|
|
952
|
+
if (cancelled) return;
|
|
953
|
+
const batch = files.slice(i, i + BATCH);
|
|
954
|
+
const results = await Promise.all(batch.map(async (f) => {
|
|
955
|
+
const dur = await getWavDuration(f.path);
|
|
956
|
+
return { ...f, duration: dur };
|
|
957
|
+
}));
|
|
958
|
+
for (const r of results) {
|
|
959
|
+
if (r.duration != null && r.duration >= 90 && r.duration <= 240) {
|
|
960
|
+
poolRef.current.push(r);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
const found = poolRef.current.length;
|
|
964
|
+
setScanProgress({ done: Math.min(i + BATCH, files.length), total: files.length, found });
|
|
965
|
+
setPoolSize(found);
|
|
966
|
+
// Start playing the first time we find a qualifying track
|
|
967
|
+
if (found >= 1 && !startedRef.current && !cancelled) {
|
|
968
|
+
startedRef.current = true;
|
|
969
|
+
setTrack(pickFromPool());
|
|
970
|
+
setLoading(false);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
if (!cancelled) {
|
|
974
|
+
setScanDone(true);
|
|
975
|
+
setPoolSize(poolRef.current.length);
|
|
976
|
+
if (!startedRef.current) {
|
|
977
|
+
startedRef.current = true;
|
|
978
|
+
if (poolRef.current.length > 0) {
|
|
979
|
+
setTrack(pickFromPool());
|
|
980
|
+
}
|
|
981
|
+
setLoading(false);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
})();
|
|
985
|
+
return () => { cancelled = true; };
|
|
986
|
+
}, []);
|
|
987
|
+
|
|
988
|
+
// Play current track
|
|
989
|
+
useEffect(() => {
|
|
990
|
+
if (!track) return;
|
|
991
|
+
|
|
992
|
+
const myVersion = ++versionRef.current;
|
|
993
|
+
const { promise, cancel, pause, resume } = playSoundWithCancel(track.path, { maxSeconds: 0 });
|
|
994
|
+
cancelRef.current = cancel;
|
|
995
|
+
pauseRef.current = pause;
|
|
996
|
+
resumeRef.current = resume;
|
|
997
|
+
setPlaying(true);
|
|
998
|
+
setPaused(false);
|
|
999
|
+
setElapsed(0);
|
|
1000
|
+
|
|
1001
|
+
promise.then(() => {
|
|
1002
|
+
if (versionRef.current === myVersion) {
|
|
1003
|
+
// Track ended naturally — pick next random from pool
|
|
1004
|
+
const next = pickFromPool();
|
|
1005
|
+
if (next) setTrack(next);
|
|
1006
|
+
}
|
|
1007
|
+
}).catch(() => {});
|
|
1008
|
+
|
|
1009
|
+
return () => cancel();
|
|
1010
|
+
}, [track]);
|
|
1011
|
+
|
|
1012
|
+
// Elapsed timer
|
|
1013
|
+
useEffect(() => {
|
|
1014
|
+
if (!playing || paused) return;
|
|
1015
|
+
const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
|
|
1016
|
+
return () => clearInterval(interval);
|
|
1017
|
+
}, [playing, paused]);
|
|
1018
|
+
|
|
1019
|
+
// Controls
|
|
1020
|
+
useInput((input, key) => {
|
|
1021
|
+
if (key.escape) {
|
|
1022
|
+
if (cancelRef.current) cancelRef.current();
|
|
1023
|
+
onBack();
|
|
1024
|
+
} else if (input === "n") {
|
|
1025
|
+
versionRef.current++;
|
|
1026
|
+
if (cancelRef.current) cancelRef.current();
|
|
1027
|
+
const next = pickFromPool();
|
|
1028
|
+
if (next) setTrack(next);
|
|
1029
|
+
} else if (input === " ") {
|
|
1030
|
+
if (paused) {
|
|
1031
|
+
if (resumeRef.current) resumeRef.current();
|
|
1032
|
+
setPaused(false);
|
|
1033
|
+
} else {
|
|
1034
|
+
if (pauseRef.current) pauseRef.current();
|
|
1035
|
+
setPaused(true);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
// Loading state
|
|
1041
|
+
if (loading) {
|
|
1042
|
+
return h(Box, { flexDirection: "column" },
|
|
1043
|
+
h(Box, { marginLeft: 2, flexDirection: "column", borderStyle: "round", borderColor: ACCENT, paddingX: 2 },
|
|
1044
|
+
h(Text, { bold: true, color: ACCENT }, `🎵 ${gameName || "Music Player"}`),
|
|
1045
|
+
h(Box, { marginTop: 1 },
|
|
1046
|
+
h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
|
|
1047
|
+
h(Text, null, ` Scanning for music tracks... ${scanProgress.found} found (${scanProgress.done}/${scanProgress.total})`),
|
|
1048
|
+
),
|
|
1049
|
+
),
|
|
1050
|
+
h(NavHint, { back: true }),
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (!track) {
|
|
1055
|
+
return h(Box, { flexDirection: "column" },
|
|
1056
|
+
h(Text, { color: "yellow", marginLeft: 2 }, " No tracks between 90s–4min found."),
|
|
1057
|
+
h(Text, { dimColor: true, marginLeft: 2 }, " Try a different game or source."),
|
|
1058
|
+
h(NavHint, { back: true }),
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const trackName = track.displayName || track.name || basename(track.path);
|
|
1063
|
+
|
|
1064
|
+
return h(Box, { flexDirection: "column" },
|
|
1065
|
+
h(Box, { marginLeft: 2, flexDirection: "column", borderStyle: "round", borderColor: paused ? "yellow" : "green", paddingX: 2 },
|
|
1066
|
+
h(Text, { bold: true, color: paused ? "yellow" : "green" }, `🎵 ${gameName || "Music Player"}`),
|
|
1067
|
+
h(Box, { marginTop: 1 },
|
|
1068
|
+
h(Text, { color: paused ? "yellow" : "green", bold: true },
|
|
1069
|
+
paused ? "⏸ " : "▶ ",
|
|
1070
|
+
),
|
|
1071
|
+
h(Text, { bold: true }, trackName),
|
|
1072
|
+
),
|
|
1073
|
+
track.gameName
|
|
1074
|
+
? h(Text, { dimColor: true }, ` ${track.gameName}`)
|
|
1075
|
+
: null,
|
|
1076
|
+
h(Text, { dimColor: true },
|
|
1077
|
+
` ${formatTime(elapsed)} / ${formatTime(track.duration || 0)}`,
|
|
1078
|
+
),
|
|
1079
|
+
),
|
|
1080
|
+
h(Box, { marginTop: 1, marginLeft: 4 },
|
|
1081
|
+
scanDone
|
|
1082
|
+
? h(Text, { dimColor: true }, `${poolSize} music tracks indexed`)
|
|
1083
|
+
: h(Box, null,
|
|
1084
|
+
h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
|
|
1085
|
+
h(Text, { dimColor: true }, ` ${poolSize} music tracks indexed (${scanProgress.done}/${scanProgress.total} scanned)`),
|
|
1086
|
+
),
|
|
1087
|
+
),
|
|
1088
|
+
h(Box, { marginTop: 1, marginLeft: 4 },
|
|
1089
|
+
h(Text, { dimColor: true }, "n next space pause esc back"),
|
|
1090
|
+
),
|
|
1091
|
+
);
|
|
1092
|
+
};
|
|
1093
|
+
|
|
806
1094
|
// ── Screen: Extracting ──────────────────────────────────────────
|
|
807
1095
|
const ExtractingScreen = ({ game, onDone, onBack }) => {
|
|
808
1096
|
const [status, setStatus] = useState("Checking cache...");
|
|
@@ -973,7 +1261,7 @@ const ConfirmScreen = ({ scope, sounds, onConfirm, onBack }) => {
|
|
|
973
1261
|
return h(Box, { flexDirection: "column" },
|
|
974
1262
|
h(Text, { bold: true, marginLeft: 2 }, " Ready to install:"),
|
|
975
1263
|
h(Box, { marginTop: 1, flexDirection: "column" },
|
|
976
|
-
h(Text, { marginLeft: 4 }, `Scope: ${scope === "global" ? "Global (Claude Code +
|
|
1264
|
+
h(Text, { marginLeft: 4 }, `Scope: ${scope === "global" ? "Global (Claude Code + Copilot)" : "This project (Claude Code + Copilot)"}`),
|
|
977
1265
|
...soundEntries.map(([eid, path]) =>
|
|
978
1266
|
h(Text, { key: eid, marginLeft: 4 },
|
|
979
1267
|
`${EVENTS[eid].name} → ${basename(path)}`
|
|
@@ -1101,6 +1389,9 @@ const InstallApp = () => {
|
|
|
1101
1389
|
const [sounds, setSounds] = useState({});
|
|
1102
1390
|
const [selectedGame, setSelectedGame] = useState(null);
|
|
1103
1391
|
const [installResult, setInstallResult] = useState(null);
|
|
1392
|
+
const [musicFiles, setMusicFiles] = useState([]);
|
|
1393
|
+
const [musicGameName, setMusicGameName] = useState(null);
|
|
1394
|
+
const [musicShuffle, setMusicShuffle] = useState(false);
|
|
1104
1395
|
|
|
1105
1396
|
const initSoundsFromPreset = useCallback((pid) => {
|
|
1106
1397
|
const preset = PRESETS[pid];
|
|
@@ -1118,12 +1409,15 @@ const InstallApp = () => {
|
|
|
1118
1409
|
});
|
|
1119
1410
|
setScreen(SCREEN.PRESET);
|
|
1120
1411
|
},
|
|
1412
|
+
onMusic: () => setScreen(SCREEN.MUSIC_MODE),
|
|
1121
1413
|
});
|
|
1122
1414
|
|
|
1123
1415
|
case SCREEN.PRESET:
|
|
1124
1416
|
return h(PresetScreen, {
|
|
1125
1417
|
onNext: (id) => {
|
|
1126
|
-
if (id === "
|
|
1418
|
+
if (id === "_music") {
|
|
1419
|
+
setScreen(SCREEN.MUSIC_MODE);
|
|
1420
|
+
} else if (id === "_scan") {
|
|
1127
1421
|
setScreen(SCREEN.GAME_PICK);
|
|
1128
1422
|
} else if (id === "_custom") {
|
|
1129
1423
|
const firstPreset = Object.keys(PRESETS)[0];
|
|
@@ -1232,6 +1526,40 @@ const InstallApp = () => {
|
|
|
1232
1526
|
case SCREEN.DONE:
|
|
1233
1527
|
return h(DoneScreen, { result: installResult });
|
|
1234
1528
|
|
|
1529
|
+
case SCREEN.MUSIC_MODE:
|
|
1530
|
+
return h(MusicModeScreen, {
|
|
1531
|
+
onRandom: () => {
|
|
1532
|
+
listCachedGames().then((games) => {
|
|
1533
|
+
const allFiles = games.flatMap((g) => g.files.map((f) => ({ ...f, gameName: g.gameName })));
|
|
1534
|
+
setMusicFiles(allFiles);
|
|
1535
|
+
setMusicGameName("All Games");
|
|
1536
|
+
setMusicShuffle(true);
|
|
1537
|
+
setScreen(SCREEN.MUSIC_PLAYING);
|
|
1538
|
+
});
|
|
1539
|
+
},
|
|
1540
|
+
onPickGame: () => setScreen(SCREEN.MUSIC_GAME_PICK),
|
|
1541
|
+
onBack: () => setScreen(SCREEN.SCOPE),
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
case SCREEN.MUSIC_GAME_PICK:
|
|
1545
|
+
return h(MusicGamePickScreen, {
|
|
1546
|
+
onNext: (game) => {
|
|
1547
|
+
setMusicFiles(game.files);
|
|
1548
|
+
setMusicGameName(game.gameName);
|
|
1549
|
+
setMusicShuffle(false);
|
|
1550
|
+
setScreen(SCREEN.MUSIC_PLAYING);
|
|
1551
|
+
},
|
|
1552
|
+
onBack: () => setScreen(SCREEN.MUSIC_MODE),
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
case SCREEN.MUSIC_PLAYING:
|
|
1556
|
+
return h(MusicPlayingScreen, {
|
|
1557
|
+
files: musicFiles,
|
|
1558
|
+
gameName: musicGameName,
|
|
1559
|
+
shuffle: musicShuffle,
|
|
1560
|
+
onBack: () => setScreen(SCREEN.MUSIC_MODE),
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1235
1563
|
default:
|
|
1236
1564
|
return h(Text, { color: "red" }, "Unknown screen");
|
|
1237
1565
|
}
|
package/src/player.js
CHANGED
|
@@ -14,7 +14,7 @@ const MEDIA_PLAYER_FORMATS = new Set([".wav", ".mp3", ".wma", ".aac"]);
|
|
|
14
14
|
/**
|
|
15
15
|
* Determine the best playback strategy for a file on the current OS.
|
|
16
16
|
*/
|
|
17
|
-
function getPlaybackCommand(absPath, { withFade = false } = {}) {
|
|
17
|
+
function getPlaybackCommand(absPath, { withFade = false, maxSeconds = MAX_PLAY_SECONDS } = {}) {
|
|
18
18
|
const os = platform();
|
|
19
19
|
const ext = extname(absPath).toLowerCase();
|
|
20
20
|
|
|
@@ -22,14 +22,14 @@ function getPlaybackCommand(absPath, { withFade = false } = {}) {
|
|
|
22
22
|
const ffplayArgs = ["-nodisp", "-autoexit", "-loglevel", "quiet"];
|
|
23
23
|
if (withFade) {
|
|
24
24
|
// silenceremove strips leading silence (below -50dB threshold)
|
|
25
|
-
// afade fades out over last FADE_SECONDS before the
|
|
26
|
-
const fadeStart =
|
|
25
|
+
// afade fades out over last FADE_SECONDS before the maxSeconds cut
|
|
26
|
+
const fadeStart = maxSeconds - FADE_SECONDS;
|
|
27
27
|
const filters = [
|
|
28
28
|
"silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB",
|
|
29
29
|
`afade=t=out:st=${fadeStart}:d=${FADE_SECONDS}`,
|
|
30
30
|
];
|
|
31
31
|
ffplayArgs.push("-af", filters.join(","));
|
|
32
|
-
ffplayArgs.push("-t", String(
|
|
32
|
+
ffplayArgs.push("-t", String(maxSeconds));
|
|
33
33
|
}
|
|
34
34
|
ffplayArgs.push(absPath);
|
|
35
35
|
|
|
@@ -197,9 +197,10 @@ export function playSound(filePath) {
|
|
|
197
197
|
* Returns { promise, cancel } — call cancel() to stop playback immediately.
|
|
198
198
|
* Playback is clamped to MAX_PLAY_SECONDS.
|
|
199
199
|
*/
|
|
200
|
-
export function playSoundWithCancel(filePath) {
|
|
200
|
+
export function playSoundWithCancel(filePath, { maxSeconds = MAX_PLAY_SECONDS } = {}) {
|
|
201
|
+
const uncapped = !maxSeconds;
|
|
201
202
|
const absPath = resolve(filePath);
|
|
202
|
-
const strategy = getPlaybackCommand(absPath, { withFade:
|
|
203
|
+
const strategy = getPlaybackCommand(absPath, { withFade: !uncapped, maxSeconds });
|
|
203
204
|
let childProcess = null;
|
|
204
205
|
let timer = null;
|
|
205
206
|
let cancelled = false;
|
|
@@ -234,48 +235,66 @@ export function playSoundWithCancel(filePath) {
|
|
|
234
235
|
}
|
|
235
236
|
|
|
236
237
|
function startExec(cmd, args) {
|
|
237
|
-
|
|
238
|
+
const execTimeout = uncapped ? 0 : (maxSeconds + 2) * 1000;
|
|
239
|
+
childProcess = execFile(cmd, args, { windowsHide: true, timeout: execTimeout }, (err) => {
|
|
238
240
|
if (err && strategy.fallback && !cancelled) {
|
|
239
241
|
if (strategy.fallback === "powershell") {
|
|
240
242
|
childProcess = execFile(
|
|
241
243
|
"powershell.exe",
|
|
242
|
-
["-NoProfile", "-Command", buildPsCommand(absPath,
|
|
243
|
-
{ windowsHide: true, timeout:
|
|
244
|
+
["-NoProfile", "-Command", buildPsCommand(absPath, uncapped ? 0 : maxSeconds)],
|
|
245
|
+
{ windowsHide: true, timeout: execTimeout },
|
|
244
246
|
(psErr) => onDone(psErr)
|
|
245
247
|
);
|
|
246
248
|
} else if (strategy.fallback === "afplay") {
|
|
247
249
|
// macOS: ffplay not available, fall back to afplay (no fade)
|
|
248
|
-
childProcess = execFile("afplay", [absPath], { timeout:
|
|
250
|
+
childProcess = execFile("afplay", [absPath], { timeout: execTimeout }, (afErr) => onDone(afErr));
|
|
249
251
|
}
|
|
250
252
|
} else {
|
|
251
253
|
onDone(err);
|
|
252
254
|
}
|
|
253
255
|
});
|
|
254
256
|
|
|
255
|
-
// Set a hard timeout to kill after
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
257
|
+
// Set a hard timeout to kill after maxSeconds (skip if uncapped)
|
|
258
|
+
if (!uncapped) {
|
|
259
|
+
timer = setTimeout(() => {
|
|
260
|
+
killChild();
|
|
261
|
+
resolvePromise();
|
|
262
|
+
}, maxSeconds * 1000);
|
|
263
|
+
}
|
|
260
264
|
}
|
|
261
265
|
|
|
262
266
|
if (strategy.type === "exec") {
|
|
263
267
|
startExec(strategy.cmd, strategy.args);
|
|
264
268
|
} else if (strategy.type === "powershell") {
|
|
269
|
+
const execTimeout = uncapped ? 0 : (maxSeconds + 2) * 1000;
|
|
265
270
|
childProcess = execFile(
|
|
266
271
|
"powershell.exe",
|
|
267
|
-
["-NoProfile", "-Command", buildPsCommand(absPath,
|
|
268
|
-
{ windowsHide: true, timeout:
|
|
272
|
+
["-NoProfile", "-Command", buildPsCommand(absPath, uncapped ? 0 : maxSeconds)],
|
|
273
|
+
{ windowsHide: true, timeout: execTimeout },
|
|
269
274
|
(err) => onDone(err)
|
|
270
275
|
);
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
276
|
+
if (!uncapped) {
|
|
277
|
+
timer = setTimeout(() => {
|
|
278
|
+
killChild();
|
|
279
|
+
resolvePromise();
|
|
280
|
+
}, maxSeconds * 1000);
|
|
281
|
+
}
|
|
275
282
|
}
|
|
276
283
|
});
|
|
277
284
|
|
|
278
|
-
|
|
285
|
+
const pause = () => {
|
|
286
|
+
if (childProcess && !childProcess.killed && platform() !== "win32") {
|
|
287
|
+
try { process.kill(childProcess.pid, "SIGSTOP"); } catch { /* ignore */ }
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const resume = () => {
|
|
292
|
+
if (childProcess && !childProcess.killed && platform() !== "win32") {
|
|
293
|
+
try { process.kill(childProcess.pid, "SIGCONT"); } catch { /* ignore */ }
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
return { promise, cancel, pause, resume };
|
|
279
298
|
}
|
|
280
299
|
|
|
281
300
|
/**
|