klaudio 0.5.3 → 0.7.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 +374 -18
- package/src/player.js +41 -22
- package/src/scanner.js +40 -0
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,14 +1,14 @@
|
|
|
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";
|
|
5
5
|
import { playSoundWithCancel, getWavDuration } from "./player.js";
|
|
6
|
-
import { getAvailableGames } from "./scanner.js";
|
|
6
|
+
import { getAvailableGames, getSystemSounds } from "./scanner.js";
|
|
7
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
|
};
|
|
@@ -134,6 +160,7 @@ const PresetScreen = ({ onNext, onBack }) => {
|
|
|
134
160
|
label: `${p.icon} ${p.name} — ${p.description}`,
|
|
135
161
|
value: id,
|
|
136
162
|
})),
|
|
163
|
+
{ label: "🔔 System sounds — use built-in OS notification sounds", value: "_system" },
|
|
137
164
|
{ label: "🕹️ Scan local games — find sounds from your Steam & Epic Games library", value: "_scan" },
|
|
138
165
|
{ label: "📁 Custom files — provide your own sound files", value: "_custom" },
|
|
139
166
|
];
|
|
@@ -450,13 +477,13 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
|
|
|
450
477
|
const meaningfulCats = categories.filter((c) => c !== "all" && (counts[c] || 0) >= 2);
|
|
451
478
|
const showCategoryPicker = meaningfulCats.length >= 2;
|
|
452
479
|
|
|
453
|
-
// Sort files: voice first, then by priority
|
|
454
|
-
const sortedFiles = hasCategories ? sortFilesByPriority(game.files) : game.files;
|
|
480
|
+
// Sort files: voice first, then by priority (memoized for stable references)
|
|
481
|
+
const sortedFiles = useMemo(() => hasCategories ? sortFilesByPriority(game.files) : game.files, [game.files, hasCategories]);
|
|
455
482
|
|
|
456
|
-
// Filter files by category (
|
|
457
|
-
const categoryFiles = activeCategory && activeCategory !== "all"
|
|
483
|
+
// Filter files by category (memoized to prevent infinite re-render loops)
|
|
484
|
+
const categoryFiles = useMemo(() => activeCategory && activeCategory !== "all"
|
|
458
485
|
? sortedFiles.filter((f) => f.category === activeCategory)
|
|
459
|
-
: sortedFiles;
|
|
486
|
+
: sortedFiles, [sortedFiles, activeCategory]);
|
|
460
487
|
|
|
461
488
|
// Stop current playback helper
|
|
462
489
|
const stopPlayback = useCallback(() => {
|
|
@@ -731,6 +758,16 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
|
|
|
731
758
|
|
|
732
759
|
// Phase 1: Browse and pick files (auto-preview plays on highlight)
|
|
733
760
|
const filterLower = filter.toLowerCase();
|
|
761
|
+
|
|
762
|
+
// Parse duration filter: "10s", "<10s", "< 10s", ">5s", "> 5s", "<=10s", ">=5s"
|
|
763
|
+
const durationFilter = useMemo(() => {
|
|
764
|
+
const m = filter.match(/^\s*(<|>|<=|>=)?\s*(\d+(?:\.\d+)?)\s*s\s*$/);
|
|
765
|
+
if (!m) return null;
|
|
766
|
+
const op = m[1] || "<=";
|
|
767
|
+
const val = parseFloat(m[2]);
|
|
768
|
+
return { op, val };
|
|
769
|
+
}, [filter]);
|
|
770
|
+
|
|
734
771
|
const allFileItems = categoryFiles.map((f) => {
|
|
735
772
|
const dur = fileDurations[f.path];
|
|
736
773
|
const durStr = dur != null ? ` (${dur}s${dur > MAX_PLAY_SECONDS ? `, preview ${MAX_PLAY_SECONDS}s` : ""})` : "";
|
|
@@ -742,11 +779,22 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
|
|
|
742
779
|
label: `${catTag}${name}${durStr}`,
|
|
743
780
|
usedTag: usedFor ? ` ← ${usedFor.join(", ")}` : null,
|
|
744
781
|
value: f.path,
|
|
782
|
+
_dur: dur,
|
|
745
783
|
};
|
|
746
784
|
});
|
|
747
785
|
|
|
748
786
|
const filteredFiles = filter
|
|
749
|
-
?
|
|
787
|
+
? durationFilter
|
|
788
|
+
? allFileItems.filter((i) => {
|
|
789
|
+
if (i._dur == null) return false;
|
|
790
|
+
const { op, val } = durationFilter;
|
|
791
|
+
if (op === "<") return i._dur < val;
|
|
792
|
+
if (op === ">") return i._dur > val;
|
|
793
|
+
if (op === "<=") return i._dur <= val;
|
|
794
|
+
if (op === ">=") return i._dur >= val;
|
|
795
|
+
return true;
|
|
796
|
+
})
|
|
797
|
+
: allFileItems.filter((i) => i.label.toLowerCase().includes(filterLower))
|
|
750
798
|
: allFileItems;
|
|
751
799
|
|
|
752
800
|
const fileItems = [
|
|
@@ -771,12 +819,12 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
|
|
|
771
819
|
),
|
|
772
820
|
filter
|
|
773
821
|
? h(Box, { marginLeft: 4 },
|
|
774
|
-
h(Text, { color: "yellow" }, "Filter: "),
|
|
822
|
+
h(Text, { color: "yellow" }, durationFilter ? "Duration: " : "Filter: "),
|
|
775
823
|
h(Text, { bold: true }, filter),
|
|
776
824
|
h(Text, { dimColor: true }, ` (${filteredFiles.length} match${filteredFiles.length !== 1 ? "es" : ""})`),
|
|
777
825
|
)
|
|
778
826
|
: categoryFiles.length > 15
|
|
779
|
-
? h(Text, { dimColor: true, marginLeft: 4 }, "Type to filter...")
|
|
827
|
+
? h(Text, { dimColor: true, marginLeft: 4 }, "Type to filter... (e.g. <10s, >5s)")
|
|
780
828
|
: null,
|
|
781
829
|
fileItems.length > 0
|
|
782
830
|
? h(Box, { marginLeft: 2 },
|
|
@@ -803,6 +851,268 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
|
|
|
803
851
|
);
|
|
804
852
|
};
|
|
805
853
|
|
|
854
|
+
// ── Helpers for Music Player ─────────────────────────────────────
|
|
855
|
+
const formatTime = (secs) => {
|
|
856
|
+
const m = Math.floor(secs / 60);
|
|
857
|
+
const s = Math.floor(secs % 60);
|
|
858
|
+
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
// ── Screen: Music Mode ──────────────────────────────────────────
|
|
862
|
+
const MusicModeScreen = ({ onRandom, onPickGame, onBack }) => {
|
|
863
|
+
useInput((_, key) => { if (key.escape) onBack(); });
|
|
864
|
+
|
|
865
|
+
const items = [
|
|
866
|
+
{ label: "🎲 Shuffle all — play random songs from all cached games", value: "random" },
|
|
867
|
+
{ label: "🎮 Play songs from game — choose a game", value: "game" },
|
|
868
|
+
];
|
|
869
|
+
|
|
870
|
+
return h(Box, { flexDirection: "column" },
|
|
871
|
+
h(Text, { bold: true, marginLeft: 2 }, " 🎵 Music Player"),
|
|
872
|
+
h(Text, { dimColor: true, marginLeft: 2 }, " Play longer game tracks as background music"),
|
|
873
|
+
h(Box, { marginTop: 1, marginLeft: 2 },
|
|
874
|
+
h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items,
|
|
875
|
+
onSelect: (item) => {
|
|
876
|
+
if (item.value === "random") onRandom();
|
|
877
|
+
else onPickGame();
|
|
878
|
+
},
|
|
879
|
+
}),
|
|
880
|
+
),
|
|
881
|
+
h(NavHint, { back: true }),
|
|
882
|
+
);
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
// ── Screen: Music Game Pick ─────────────────────────────────────
|
|
886
|
+
const MusicGamePickScreen = ({ onNext, onBack }) => {
|
|
887
|
+
const [games, setGames] = useState([]);
|
|
888
|
+
const [loading, setLoading] = useState(true);
|
|
889
|
+
|
|
890
|
+
useInput((_, key) => { if (key.escape) onBack(); });
|
|
891
|
+
|
|
892
|
+
useEffect(() => {
|
|
893
|
+
let cancelled = false;
|
|
894
|
+
listCachedGames().then((cached) => {
|
|
895
|
+
if (!cancelled) {
|
|
896
|
+
setGames(cached);
|
|
897
|
+
setLoading(false);
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
return () => { cancelled = true; };
|
|
901
|
+
}, []);
|
|
902
|
+
|
|
903
|
+
if (loading) {
|
|
904
|
+
return h(Box, { flexDirection: "column" },
|
|
905
|
+
h(Box, { marginLeft: 2 },
|
|
906
|
+
h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
|
|
907
|
+
h(Text, null, " Scanning cached games..."),
|
|
908
|
+
),
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (games.length === 0) {
|
|
913
|
+
return h(Box, { flexDirection: "column" },
|
|
914
|
+
h(Text, { color: "yellow", marginLeft: 2 }, " No cached games found."),
|
|
915
|
+
h(Text, { dimColor: true, marginLeft: 2 }, " Use \"Scan local games\" first to extract game audio."),
|
|
916
|
+
h(NavHint, { back: true }),
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const items = games.map((g) => ({
|
|
921
|
+
label: `${g.gameName} (${g.files.length} files)`,
|
|
922
|
+
value: g.gameName,
|
|
923
|
+
}));
|
|
924
|
+
|
|
925
|
+
return h(Box, { flexDirection: "column" },
|
|
926
|
+
h(Text, { bold: true, marginLeft: 2 }, " Pick a game:"),
|
|
927
|
+
h(Box, { marginLeft: 2 },
|
|
928
|
+
h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, limit: 15,
|
|
929
|
+
onSelect: (item) => {
|
|
930
|
+
const game = games.find((g) => g.gameName === item.value);
|
|
931
|
+
onNext(game);
|
|
932
|
+
},
|
|
933
|
+
}),
|
|
934
|
+
),
|
|
935
|
+
h(NavHint, { back: true }),
|
|
936
|
+
);
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
// ── Screen: Music Playing ───────────────────────────────────────
|
|
940
|
+
const MusicPlayingScreen = ({ files, gameName, shuffle: initialShuffle, onBack }) => {
|
|
941
|
+
const [track, setTrack] = useState(null); // current track { path, name, displayName, duration }
|
|
942
|
+
const [loading, setLoading] = useState(true);
|
|
943
|
+
const [scanProgress, setScanProgress] = useState({ done: 0, total: files.length, found: 0 });
|
|
944
|
+
const [scanDone, setScanDone] = useState(false);
|
|
945
|
+
const [playing, setPlaying] = useState(false);
|
|
946
|
+
const [paused, setPaused] = useState(false);
|
|
947
|
+
const [elapsed, setElapsed] = useState(0);
|
|
948
|
+
const [poolSize, setPoolSize] = useState(0);
|
|
949
|
+
const cancelRef = useRef(null);
|
|
950
|
+
const pauseRef = useRef(null);
|
|
951
|
+
const resumeRef = useRef(null);
|
|
952
|
+
const versionRef = useRef(0);
|
|
953
|
+
const poolRef = useRef([]); // ever-growing pool of qualifying tracks
|
|
954
|
+
const lastPlayedRef = useRef(-1); // index of last played track in pool
|
|
955
|
+
|
|
956
|
+
// Pick a random track from the pool (different from last played)
|
|
957
|
+
const pickFromPool = useCallback(() => {
|
|
958
|
+
const pool = poolRef.current;
|
|
959
|
+
if (pool.length === 0) return null;
|
|
960
|
+
if (pool.length === 1) return pool[0];
|
|
961
|
+
let idx;
|
|
962
|
+
do { idx = Math.floor(Math.random() * pool.length); } while (idx === lastPlayedRef.current && pool.length > 1);
|
|
963
|
+
lastPlayedRef.current = idx;
|
|
964
|
+
return pool[idx];
|
|
965
|
+
}, []);
|
|
966
|
+
|
|
967
|
+
// Scan files for duration (90s-4min), pick first random once found, keep scanning in background
|
|
968
|
+
const startedRef = useRef(false);
|
|
969
|
+
useEffect(() => {
|
|
970
|
+
let cancelled = false;
|
|
971
|
+
(async () => {
|
|
972
|
+
const BATCH = 20;
|
|
973
|
+
for (let i = 0; i < files.length; i += BATCH) {
|
|
974
|
+
if (cancelled) return;
|
|
975
|
+
const batch = files.slice(i, i + BATCH);
|
|
976
|
+
const results = await Promise.all(batch.map(async (f) => {
|
|
977
|
+
const dur = await getWavDuration(f.path);
|
|
978
|
+
return { ...f, duration: dur };
|
|
979
|
+
}));
|
|
980
|
+
for (const r of results) {
|
|
981
|
+
if (r.duration != null && r.duration >= 90 && r.duration <= 240) {
|
|
982
|
+
poolRef.current.push(r);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
const found = poolRef.current.length;
|
|
986
|
+
setScanProgress({ done: Math.min(i + BATCH, files.length), total: files.length, found });
|
|
987
|
+
setPoolSize(found);
|
|
988
|
+
// Start playing the first time we find a qualifying track
|
|
989
|
+
if (found >= 1 && !startedRef.current && !cancelled) {
|
|
990
|
+
startedRef.current = true;
|
|
991
|
+
setTrack(pickFromPool());
|
|
992
|
+
setLoading(false);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
if (!cancelled) {
|
|
996
|
+
setScanDone(true);
|
|
997
|
+
setPoolSize(poolRef.current.length);
|
|
998
|
+
if (!startedRef.current) {
|
|
999
|
+
startedRef.current = true;
|
|
1000
|
+
if (poolRef.current.length > 0) {
|
|
1001
|
+
setTrack(pickFromPool());
|
|
1002
|
+
}
|
|
1003
|
+
setLoading(false);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
})();
|
|
1007
|
+
return () => { cancelled = true; };
|
|
1008
|
+
}, []);
|
|
1009
|
+
|
|
1010
|
+
// Play current track
|
|
1011
|
+
useEffect(() => {
|
|
1012
|
+
if (!track) return;
|
|
1013
|
+
|
|
1014
|
+
const myVersion = ++versionRef.current;
|
|
1015
|
+
const { promise, cancel, pause, resume } = playSoundWithCancel(track.path, { maxSeconds: 0 });
|
|
1016
|
+
cancelRef.current = cancel;
|
|
1017
|
+
pauseRef.current = pause;
|
|
1018
|
+
resumeRef.current = resume;
|
|
1019
|
+
setPlaying(true);
|
|
1020
|
+
setPaused(false);
|
|
1021
|
+
setElapsed(0);
|
|
1022
|
+
|
|
1023
|
+
promise.then(() => {
|
|
1024
|
+
if (versionRef.current === myVersion) {
|
|
1025
|
+
// Track ended naturally — pick next random from pool
|
|
1026
|
+
const next = pickFromPool();
|
|
1027
|
+
if (next) setTrack(next);
|
|
1028
|
+
}
|
|
1029
|
+
}).catch(() => {});
|
|
1030
|
+
|
|
1031
|
+
return () => cancel();
|
|
1032
|
+
}, [track]);
|
|
1033
|
+
|
|
1034
|
+
// Elapsed timer
|
|
1035
|
+
useEffect(() => {
|
|
1036
|
+
if (!playing || paused) return;
|
|
1037
|
+
const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
|
|
1038
|
+
return () => clearInterval(interval);
|
|
1039
|
+
}, [playing, paused]);
|
|
1040
|
+
|
|
1041
|
+
// Controls
|
|
1042
|
+
useInput((input, key) => {
|
|
1043
|
+
if (key.escape) {
|
|
1044
|
+
if (cancelRef.current) cancelRef.current();
|
|
1045
|
+
onBack();
|
|
1046
|
+
} else if (input === "n") {
|
|
1047
|
+
versionRef.current++;
|
|
1048
|
+
if (cancelRef.current) cancelRef.current();
|
|
1049
|
+
const next = pickFromPool();
|
|
1050
|
+
if (next) setTrack(next);
|
|
1051
|
+
} else if (input === " ") {
|
|
1052
|
+
if (paused) {
|
|
1053
|
+
if (resumeRef.current) resumeRef.current();
|
|
1054
|
+
setPaused(false);
|
|
1055
|
+
} else {
|
|
1056
|
+
if (pauseRef.current) pauseRef.current();
|
|
1057
|
+
setPaused(true);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
// Loading state
|
|
1063
|
+
if (loading) {
|
|
1064
|
+
return h(Box, { flexDirection: "column" },
|
|
1065
|
+
h(Box, { marginLeft: 2, flexDirection: "column", borderStyle: "round", borderColor: ACCENT, paddingX: 2 },
|
|
1066
|
+
h(Text, { bold: true, color: ACCENT }, `🎵 ${gameName || "Music Player"}`),
|
|
1067
|
+
h(Box, { marginTop: 1 },
|
|
1068
|
+
h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
|
|
1069
|
+
h(Text, null, ` Scanning for music tracks... ${scanProgress.found} found (${scanProgress.done}/${scanProgress.total})`),
|
|
1070
|
+
),
|
|
1071
|
+
),
|
|
1072
|
+
h(NavHint, { back: true }),
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
if (!track) {
|
|
1077
|
+
return h(Box, { flexDirection: "column" },
|
|
1078
|
+
h(Text, { color: "yellow", marginLeft: 2 }, " No tracks between 90s–4min found."),
|
|
1079
|
+
h(Text, { dimColor: true, marginLeft: 2 }, " Try a different game or source."),
|
|
1080
|
+
h(NavHint, { back: true }),
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const trackName = track.displayName || track.name || basename(track.path);
|
|
1085
|
+
|
|
1086
|
+
return h(Box, { flexDirection: "column" },
|
|
1087
|
+
h(Box, { marginLeft: 2, flexDirection: "column", borderStyle: "round", borderColor: paused ? "yellow" : "green", paddingX: 2 },
|
|
1088
|
+
h(Text, { bold: true, color: paused ? "yellow" : "green" }, `🎵 ${gameName || "Music Player"}`),
|
|
1089
|
+
h(Box, { marginTop: 1 },
|
|
1090
|
+
h(Text, { color: paused ? "yellow" : "green", bold: true },
|
|
1091
|
+
paused ? "⏸ " : "▶ ",
|
|
1092
|
+
),
|
|
1093
|
+
h(Text, { bold: true }, trackName),
|
|
1094
|
+
),
|
|
1095
|
+
track.gameName
|
|
1096
|
+
? h(Text, { dimColor: true }, ` ${track.gameName}`)
|
|
1097
|
+
: null,
|
|
1098
|
+
h(Text, { dimColor: true },
|
|
1099
|
+
` ${formatTime(elapsed)} / ${formatTime(track.duration || 0)}`,
|
|
1100
|
+
),
|
|
1101
|
+
),
|
|
1102
|
+
h(Box, { marginTop: 1, marginLeft: 4 },
|
|
1103
|
+
scanDone
|
|
1104
|
+
? h(Text, { dimColor: true }, `${poolSize} music tracks indexed`)
|
|
1105
|
+
: h(Box, null,
|
|
1106
|
+
h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
|
|
1107
|
+
h(Text, { dimColor: true }, ` ${poolSize} music tracks indexed (${scanProgress.done}/${scanProgress.total} scanned)`),
|
|
1108
|
+
),
|
|
1109
|
+
),
|
|
1110
|
+
h(Box, { marginTop: 1, marginLeft: 4 },
|
|
1111
|
+
h(Text, { dimColor: true }, "n next space pause esc back"),
|
|
1112
|
+
),
|
|
1113
|
+
);
|
|
1114
|
+
};
|
|
1115
|
+
|
|
806
1116
|
// ── Screen: Extracting ──────────────────────────────────────────
|
|
807
1117
|
const ExtractingScreen = ({ game, onDone, onBack }) => {
|
|
808
1118
|
const [status, setStatus] = useState("Checking cache...");
|
|
@@ -973,7 +1283,7 @@ const ConfirmScreen = ({ scope, sounds, onConfirm, onBack }) => {
|
|
|
973
1283
|
return h(Box, { flexDirection: "column" },
|
|
974
1284
|
h(Text, { bold: true, marginLeft: 2 }, " Ready to install:"),
|
|
975
1285
|
h(Box, { marginTop: 1, flexDirection: "column" },
|
|
976
|
-
h(Text, { marginLeft: 4 }, `Scope: ${scope === "global" ? "Global (Claude Code +
|
|
1286
|
+
h(Text, { marginLeft: 4 }, `Scope: ${scope === "global" ? "Global (Claude Code + Copilot)" : "This project (Claude Code + Copilot)"}`),
|
|
977
1287
|
...soundEntries.map(([eid, path]) =>
|
|
978
1288
|
h(Text, { key: eid, marginLeft: 4 },
|
|
979
1289
|
`${EVENTS[eid].name} → ${basename(path)}`
|
|
@@ -1101,6 +1411,9 @@ const InstallApp = () => {
|
|
|
1101
1411
|
const [sounds, setSounds] = useState({});
|
|
1102
1412
|
const [selectedGame, setSelectedGame] = useState(null);
|
|
1103
1413
|
const [installResult, setInstallResult] = useState(null);
|
|
1414
|
+
const [musicFiles, setMusicFiles] = useState([]);
|
|
1415
|
+
const [musicGameName, setMusicGameName] = useState(null);
|
|
1416
|
+
const [musicShuffle, setMusicShuffle] = useState(false);
|
|
1104
1417
|
|
|
1105
1418
|
const initSoundsFromPreset = useCallback((pid) => {
|
|
1106
1419
|
const preset = PRESETS[pid];
|
|
@@ -1118,12 +1431,21 @@ const InstallApp = () => {
|
|
|
1118
1431
|
});
|
|
1119
1432
|
setScreen(SCREEN.PRESET);
|
|
1120
1433
|
},
|
|
1434
|
+
onMusic: () => setScreen(SCREEN.MUSIC_MODE),
|
|
1121
1435
|
});
|
|
1122
1436
|
|
|
1123
1437
|
case SCREEN.PRESET:
|
|
1124
1438
|
return h(PresetScreen, {
|
|
1125
1439
|
onNext: (id) => {
|
|
1126
|
-
if (id === "
|
|
1440
|
+
if (id === "_music") {
|
|
1441
|
+
setScreen(SCREEN.MUSIC_MODE);
|
|
1442
|
+
} else if (id === "_system") {
|
|
1443
|
+
getSystemSounds().then((files) => {
|
|
1444
|
+
const catFiles = categorizeLooseFiles(files);
|
|
1445
|
+
setSelectedGame({ name: "System Sounds", path: "", files: catFiles, fileCount: catFiles.length, hasAudio: catFiles.length > 0 });
|
|
1446
|
+
setScreen(SCREEN.GAME_SOUNDS);
|
|
1447
|
+
});
|
|
1448
|
+
} else if (id === "_scan") {
|
|
1127
1449
|
setScreen(SCREEN.GAME_PICK);
|
|
1128
1450
|
} else if (id === "_custom") {
|
|
1129
1451
|
const firstPreset = Object.keys(PRESETS)[0];
|
|
@@ -1232,6 +1554,40 @@ const InstallApp = () => {
|
|
|
1232
1554
|
case SCREEN.DONE:
|
|
1233
1555
|
return h(DoneScreen, { result: installResult });
|
|
1234
1556
|
|
|
1557
|
+
case SCREEN.MUSIC_MODE:
|
|
1558
|
+
return h(MusicModeScreen, {
|
|
1559
|
+
onRandom: () => {
|
|
1560
|
+
listCachedGames().then((games) => {
|
|
1561
|
+
const allFiles = games.flatMap((g) => g.files.map((f) => ({ ...f, gameName: g.gameName })));
|
|
1562
|
+
setMusicFiles(allFiles);
|
|
1563
|
+
setMusicGameName("All Games");
|
|
1564
|
+
setMusicShuffle(true);
|
|
1565
|
+
setScreen(SCREEN.MUSIC_PLAYING);
|
|
1566
|
+
});
|
|
1567
|
+
},
|
|
1568
|
+
onPickGame: () => setScreen(SCREEN.MUSIC_GAME_PICK),
|
|
1569
|
+
onBack: () => setScreen(SCREEN.SCOPE),
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
case SCREEN.MUSIC_GAME_PICK:
|
|
1573
|
+
return h(MusicGamePickScreen, {
|
|
1574
|
+
onNext: (game) => {
|
|
1575
|
+
setMusicFiles(game.files);
|
|
1576
|
+
setMusicGameName(game.gameName);
|
|
1577
|
+
setMusicShuffle(false);
|
|
1578
|
+
setScreen(SCREEN.MUSIC_PLAYING);
|
|
1579
|
+
},
|
|
1580
|
+
onBack: () => setScreen(SCREEN.MUSIC_MODE),
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
case SCREEN.MUSIC_PLAYING:
|
|
1584
|
+
return h(MusicPlayingScreen, {
|
|
1585
|
+
files: musicFiles,
|
|
1586
|
+
gameName: musicGameName,
|
|
1587
|
+
shuffle: musicShuffle,
|
|
1588
|
+
onBack: () => setScreen(SCREEN.MUSIC_MODE),
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1235
1591
|
default:
|
|
1236
1592
|
return h(Text, { color: "red" }, "Unknown screen");
|
|
1237
1593
|
}
|
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
|
/**
|
package/src/scanner.js
CHANGED
|
@@ -379,6 +379,46 @@ export async function scanForGames(onProgress, onGameFound) {
|
|
|
379
379
|
return games;
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Discover system sounds (Windows Media, macOS system sounds, Linux sound themes).
|
|
384
|
+
* Returns an array of { path, name, dir } matching the game file format.
|
|
385
|
+
*/
|
|
386
|
+
export async function getSystemSounds() {
|
|
387
|
+
const os = platform();
|
|
388
|
+
const results = [];
|
|
389
|
+
|
|
390
|
+
const dirs = [];
|
|
391
|
+
if (os === "win32") {
|
|
392
|
+
dirs.push("C:/Windows/Media", "C:/Windows/Media/dm");
|
|
393
|
+
} else if (os === "darwin") {
|
|
394
|
+
dirs.push("/System/Library/Sounds");
|
|
395
|
+
} else {
|
|
396
|
+
// Linux: common sound theme locations
|
|
397
|
+
dirs.push(
|
|
398
|
+
"/usr/share/sounds/freedesktop/stereo",
|
|
399
|
+
"/usr/share/sounds/gnome/default/alerts",
|
|
400
|
+
"/usr/share/sounds/ubuntu/stereo",
|
|
401
|
+
"/usr/share/sounds",
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
for (const dir of dirs) {
|
|
406
|
+
if (!(await dirExists(dir))) continue;
|
|
407
|
+
try {
|
|
408
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
409
|
+
for (const entry of entries) {
|
|
410
|
+
if (!entry.isFile()) continue;
|
|
411
|
+
const ext = extname(entry.name).toLowerCase();
|
|
412
|
+
if (AUDIO_EXTENSIONS.has(ext)) {
|
|
413
|
+
results.push({ path: join(dir, entry.name), name: entry.name, dir });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
} catch { /* skip */ }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return results;
|
|
420
|
+
}
|
|
421
|
+
|
|
382
422
|
/**
|
|
383
423
|
* Get a flat list of all found game directories.
|
|
384
424
|
*/
|