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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klaudio",
3
- "version": "0.5.3",
3
+ "version": "0.7.0",
4
4
  "description": "Add sound effects to your coding sessions — play sounds when tasks complete, notifications arrive, and more",
5
5
  "type": "module",
6
6
  "bin": {
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 + VS Code Copilot (all projects)", value: "global" },
118
- { label: "This project — Claude Code + Copilot (incl. GitHub agent)", value: "project" },
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
- h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, onSelect: (item) => onNext(item.value) }),
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 (no hard cap SelectInput handles visible window)
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
- ? allFileItems.filter((i) => i.label.toLowerCase().includes(filterLower))
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 + VS Code Copilot)" : "This project (Claude Code + Copilot + GitHub agent)"}`),
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 === "_scan") {
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 MAX_PLAY_SECONDS cut
26
- const fadeStart = MAX_PLAY_SECONDS - FADE_SECONDS;
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(MAX_PLAY_SECONDS));
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: true });
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
- childProcess = execFile(cmd, args, { windowsHide: true, timeout: (MAX_PLAY_SECONDS + 2) * 1000 }, (err) => {
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, MAX_PLAY_SECONDS)],
243
- { windowsHide: true, timeout: (MAX_PLAY_SECONDS + 2) * 1000 },
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: (MAX_PLAY_SECONDS + 2) * 1000 }, (afErr) => onDone(afErr));
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 MAX_PLAY_SECONDS
256
- timer = setTimeout(() => {
257
- killChild();
258
- resolvePromise();
259
- }, MAX_PLAY_SECONDS * 1000);
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, MAX_PLAY_SECONDS)],
268
- { windowsHide: true, timeout: (MAX_PLAY_SECONDS + 2) * 1000 },
272
+ ["-NoProfile", "-Command", buildPsCommand(absPath, uncapped ? 0 : maxSeconds)],
273
+ { windowsHide: true, timeout: execTimeout },
269
274
  (err) => onDone(err)
270
275
  );
271
- timer = setTimeout(() => {
272
- killChild();
273
- resolvePromise();
274
- }, MAX_PLAY_SECONDS * 1000);
276
+ if (!uncapped) {
277
+ timer = setTimeout(() => {
278
+ killChild();
279
+ resolvePromise();
280
+ }, maxSeconds * 1000);
281
+ }
275
282
  }
276
283
  });
277
284
 
278
- return { promise, cancel };
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
  */