agentcraft 0.0.4 → 0.0.5
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/.claude-plugin/plugin.json +1 -1
- package/TODO.md +29 -0
- package/bin/agentcraft.js +110 -7
- package/commands/agentcraft.md +2 -8
- package/hooks/play-sound.sh +5 -2
- package/package.json +1 -1
- package/skills/packs/SKILL.md +11 -2
- package/web/app/api/preview/route.ts +3 -2
- package/web/app/page.tsx +10 -3
- package/web/components/hud-header.tsx +23 -1
- package/web/components/sound-browser-panel.tsx +96 -18
- package/web/components/sound-unit.tsx +7 -1
- package/web/components/ui-sounds-modal.tsx +34 -8
- package/web/lib/types.ts +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentcraft",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Assign sounds to AI coding agent lifecycle events. Works with Claude Code and OpenCode.",
|
|
5
5
|
"author": { "name": "rohenaz" },
|
|
6
6
|
"keywords": ["sounds", "hooks", "audio", "productivity", "opencode", "claude-code"]
|
package/TODO.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# AgentCraft TODO
|
|
2
|
+
|
|
3
|
+
## Features
|
|
4
|
+
|
|
5
|
+
### Push-to-Talk / Dictation Mute Key
|
|
6
|
+
Assign a configurable hotkey (default: Fn key) that suppresses sound playback while held.
|
|
7
|
+
Use case: PTT dictation apps (Whisper, SuperWhisper, etc.) use a held key to record. Sounds
|
|
8
|
+
firing during active recording interrupt or bleed into the mic capture.
|
|
9
|
+
|
|
10
|
+
**Design notes:**
|
|
11
|
+
- Settings UI: "MUTE WHILE HELD" key picker in the dashboard (default `Fn`, allow any key)
|
|
12
|
+
- Stored in `assignments.json` under `settings.muteKey` (e.g. `"Fn"`, `"CapsLock"`, `"F13"`)
|
|
13
|
+
- Detection: the hook script can't read keyboard state directly. Options:
|
|
14
|
+
1. **Lock file approach** — dashboard JS writes `/tmp/agentcraft-muted.lock` on `keydown`
|
|
15
|
+
and removes it on `keyup`. Hook script checks for file existence before playing.
|
|
16
|
+
Requires the dashboard to be open (reasonable — it's a config tool).
|
|
17
|
+
2. **Daemon approach** — small background process (bun script) listens to keyboard events
|
|
18
|
+
via IOKit/CGEvent on macOS and manages the lock file. Survives without dashboard open.
|
|
19
|
+
3. **Passive detection** — read `/proc/bus/input` on Linux or `hidutil` output on macOS
|
|
20
|
+
to poll key state at hook execution time (complex, likely too slow).
|
|
21
|
+
- Recommended: start with option 1 (lock file via dashboard), add option 2 as optional daemon.
|
|
22
|
+
- The `play-sound.sh` check would be: `[ -f "/tmp/agentcraft-muted.lock" ] && exit 0`
|
|
23
|
+
- Dashboard global keydown/keyup listeners write/remove the lock file via a small API route
|
|
24
|
+
(`POST /api/mute { active: boolean }`)
|
|
25
|
+
|
|
26
|
+
### Sound Browser SOURCE Dropdown (multi-pack filter)
|
|
27
|
+
Gemini Option A: compact SOURCE dropdown above group tabs, invisible at 1 pack, appears at 2+.
|
|
28
|
+
Filters the sound browser to a specific pack or shows all with pack badges on cards.
|
|
29
|
+
See: conversation history / Gemini recommendation for full spec.
|
package/bin/agentcraft.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const { execSync, spawnSync } = require('child_process');
|
|
5
|
-
const { existsSync, readdirSync, rmSync, statSync, mkdirSync, readFileSync, copyFileSync, realpathSync } = require('fs');
|
|
5
|
+
const { existsSync, readdirSync, rmSync, statSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, realpathSync } = require('fs');
|
|
6
6
|
const { join, dirname } = require('path');
|
|
7
7
|
const { homedir } = require('os');
|
|
8
8
|
|
|
@@ -139,6 +139,105 @@ function packInit() {
|
|
|
139
139
|
console.log(` Browse packs: ${c.cyan('agentcraft list')}`);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
function createPack(name) {
|
|
143
|
+
if (!name) {
|
|
144
|
+
console.error(c.red('Usage: agentcraft create-pack <name>'));
|
|
145
|
+
console.error(c.dim(' Example: agentcraft create-pack my-sounds'));
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
if (existsSync(name)) {
|
|
149
|
+
console.error(c.red(`✗ Directory already exists: ${name}`));
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Try to detect GitHub username from git config
|
|
154
|
+
let publisher = 'your-github-username';
|
|
155
|
+
try {
|
|
156
|
+
const gitUser = execSync('git config --global user.name 2>/dev/null', { encoding: 'utf-8' }).trim();
|
|
157
|
+
const gitEmail = execSync('git config --global github.user 2>/dev/null', { encoding: 'utf-8' }).trim();
|
|
158
|
+
if (gitEmail) publisher = gitEmail;
|
|
159
|
+
else if (gitUser) publisher = gitUser.toLowerCase().replace(/\s+/g, '-');
|
|
160
|
+
} catch { /* use default */ }
|
|
161
|
+
|
|
162
|
+
console.log(`→ Creating pack ${c.cyan(name)} ...`);
|
|
163
|
+
|
|
164
|
+
// Directory structure
|
|
165
|
+
ensureDir(join(name, 'sounds', 'session-start'));
|
|
166
|
+
ensureDir(join(name, 'sounds', 'task-complete'));
|
|
167
|
+
ensureDir(join(name, 'sounds', 'error'));
|
|
168
|
+
ensureDir(join(name, 'ui', 'my-theme'));
|
|
169
|
+
|
|
170
|
+
// pack.json
|
|
171
|
+
writeFileSync(join(name, 'pack.json'), JSON.stringify({
|
|
172
|
+
name,
|
|
173
|
+
publisher,
|
|
174
|
+
version: '1.0.0',
|
|
175
|
+
description: 'My AgentCraft sound pack',
|
|
176
|
+
types: ['sounds'],
|
|
177
|
+
}, null, 2) + '\n');
|
|
178
|
+
|
|
179
|
+
// .gitignore
|
|
180
|
+
writeFileSync(join(name, '.gitignore'), '.DS_Store\nThumbs.db\n');
|
|
181
|
+
|
|
182
|
+
// README.md
|
|
183
|
+
writeFileSync(join(name, 'README.md'), `# ${name}
|
|
184
|
+
|
|
185
|
+
An [AgentCraft](https://github.com/rohenaz/agentcraft) sound pack.
|
|
186
|
+
|
|
187
|
+
## Install
|
|
188
|
+
|
|
189
|
+
\`\`\`bash
|
|
190
|
+
agentcraft add ${publisher}/${name}
|
|
191
|
+
\`\`\`
|
|
192
|
+
|
|
193
|
+
Or manually:
|
|
194
|
+
|
|
195
|
+
\`\`\`bash
|
|
196
|
+
git clone https://github.com/${publisher}/${name} ~/.agentcraft/packs/${publisher}/${name}
|
|
197
|
+
\`\`\`
|
|
198
|
+
|
|
199
|
+
## Structure
|
|
200
|
+
|
|
201
|
+
Drop \`.mp3\`, \`.wav\`, \`.ogg\`, or \`.m4a\` files into directories. The dashboard auto-discovers them:
|
|
202
|
+
|
|
203
|
+
\`\`\`
|
|
204
|
+
sounds/
|
|
205
|
+
session-start/ ← sounds here appear under "sounds > session start" group
|
|
206
|
+
task-complete/
|
|
207
|
+
error/
|
|
208
|
+
ui/
|
|
209
|
+
my-theme/ ← optional: UI sounds for the dashboard itself
|
|
210
|
+
click.mp3
|
|
211
|
+
hover.mp3
|
|
212
|
+
confirm.mp3
|
|
213
|
+
error.mp3
|
|
214
|
+
pageChange.mp3
|
|
215
|
+
\`\`\`
|
|
216
|
+
|
|
217
|
+
Top-level directories become group tabs. Nested directories become sub-tabs and subcategories.
|
|
218
|
+
Any layout works — organise however makes sense for your sounds.
|
|
219
|
+
|
|
220
|
+
## Publishing
|
|
221
|
+
|
|
222
|
+
1. Push this repo to GitHub as \`${publisher}/${name}\`
|
|
223
|
+
2. Go to **Settings → Topics** and add the topic \`agentcraft-pack\`
|
|
224
|
+
3. The community registry picks it up within 6 hours
|
|
225
|
+
|
|
226
|
+
Users can then find and install it from the AgentCraft dashboard PACKS tab.
|
|
227
|
+
`);
|
|
228
|
+
|
|
229
|
+
console.log(c.green(`✓ Created ${name}/`));
|
|
230
|
+
console.log('');
|
|
231
|
+
console.log('Next steps:');
|
|
232
|
+
console.log(` 1. Drop ${c.cyan('.mp3/.wav/.ogg')} files into the subdirectories`);
|
|
233
|
+
console.log(` 2. Push to GitHub as ${c.cyan(`${publisher}/${name}`)}`);
|
|
234
|
+
console.log(` 3. Add topic ${c.cyan('agentcraft-pack')} in GitHub Settings → Topics`);
|
|
235
|
+
console.log('');
|
|
236
|
+
console.log('Test locally before publishing:');
|
|
237
|
+
console.log(c.dim(` git clone . ~/.agentcraft/packs/${publisher}/${name}`));
|
|
238
|
+
console.log(c.dim(' agentcraft start'));
|
|
239
|
+
}
|
|
240
|
+
|
|
142
241
|
function getWebDir() {
|
|
143
242
|
if (process.env.CLAUDE_PLUGIN_ROOT) {
|
|
144
243
|
return join(process.env.CLAUDE_PLUGIN_ROOT, 'web');
|
|
@@ -157,12 +256,13 @@ function showHelp() {
|
|
|
157
256
|
${c.bold('AgentCraft')} — assign sounds to AI agent lifecycle events
|
|
158
257
|
|
|
159
258
|
${c.cyan('Usage:')}
|
|
160
|
-
agentcraft init
|
|
161
|
-
agentcraft add <publisher/name>
|
|
162
|
-
agentcraft remove <publisher/name>
|
|
163
|
-
agentcraft update [publisher/name]
|
|
164
|
-
agentcraft list
|
|
165
|
-
agentcraft start
|
|
259
|
+
agentcraft init Set up AgentCraft (install pack + config)
|
|
260
|
+
agentcraft add <publisher/name> Install a sound pack from GitHub
|
|
261
|
+
agentcraft remove <publisher/name> Remove an installed pack
|
|
262
|
+
agentcraft update [publisher/name] Update a pack, or all packs if no arg given
|
|
263
|
+
agentcraft list List installed packs
|
|
264
|
+
agentcraft start Launch the dashboard (port 4040)
|
|
265
|
+
agentcraft create-pack <name> Scaffold a new sound pack repo
|
|
166
266
|
|
|
167
267
|
${c.cyan('Examples:')}
|
|
168
268
|
agentcraft init
|
|
@@ -170,6 +270,7 @@ ${c.cyan('Examples:')}
|
|
|
170
270
|
agentcraft add publisher/custom-pack
|
|
171
271
|
agentcraft update
|
|
172
272
|
agentcraft list
|
|
273
|
+
agentcraft create-pack my-sounds
|
|
173
274
|
|
|
174
275
|
${c.dim('Packs are stored at: ~/.agentcraft/packs/<publisher>/<name>/')}
|
|
175
276
|
${c.dim('Any git repo cloned there is automatically discovered by the dashboard.')}
|
|
@@ -208,6 +309,8 @@ if (cmd === 'init') {
|
|
|
208
309
|
execSync(`cd "${webDir}" && bun install --silent && bun dev --port 4040`, { stdio: 'inherit' });
|
|
209
310
|
} else if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
210
311
|
showHelp();
|
|
312
|
+
} else if (cmd === 'create-pack') {
|
|
313
|
+
createPack(sub);
|
|
211
314
|
} else if (cmd === 'pack') {
|
|
212
315
|
// Legacy shim — print migration hint and route through
|
|
213
316
|
const newCmd = sub === 'install' ? 'add' : sub;
|
package/commands/agentcraft.md
CHANGED
|
@@ -12,15 +12,9 @@ kill $(lsof -ti:4040) 2>/dev/null
|
|
|
12
12
|
echo "AgentCraft server stopped."
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Run first-time setup (installs official pack + creates assignments.json if missing):
|
|
16
16
|
```bash
|
|
17
|
-
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
If that returned nothing (empty or missing), install it:
|
|
21
|
-
```bash
|
|
22
|
-
agentcraft pack install rohenaz/agentcraft-sounds 2>/dev/null || \
|
|
23
|
-
git clone https://github.com/rohenaz/agentcraft-sounds ~/.agentcraft/packs/rohenaz/agentcraft-sounds
|
|
17
|
+
agentcraft init
|
|
24
18
|
```
|
|
25
19
|
|
|
26
20
|
Check if server is already running:
|
package/hooks/play-sound.sh
CHANGED
|
@@ -17,6 +17,8 @@ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
|
|
|
17
17
|
ENABLED=$(jq -r '.settings.enabled // true' "$CONFIG")
|
|
18
18
|
[ "$ENABLED" = "false" ] && exit 0
|
|
19
19
|
|
|
20
|
+
VOLUME=$(jq -r '.settings.masterVolume // 1' "$CONFIG")
|
|
21
|
+
|
|
20
22
|
# Deduplicate: prevent the same event from firing within 3 seconds.
|
|
21
23
|
# Claude Code fires SessionStart twice on resume (process init + session restore).
|
|
22
24
|
LOCKFILE="/tmp/agentcraft-${EVENT}.lock"
|
|
@@ -70,9 +72,10 @@ fi
|
|
|
70
72
|
[ ! -f "$FULL" ] && exit 0
|
|
71
73
|
|
|
72
74
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
73
|
-
afplay "$FULL" &
|
|
75
|
+
afplay -v "$VOLUME" "$FULL" &
|
|
74
76
|
elif command -v paplay &>/dev/null; then
|
|
75
|
-
|
|
77
|
+
VOLUME_PAPLAY=$(awk "BEGIN{printf \"%d\", $VOLUME * 65536}")
|
|
78
|
+
paplay --volume="$VOLUME_PAPLAY" "$FULL" &
|
|
76
79
|
elif command -v aplay &>/dev/null; then
|
|
77
80
|
aplay "$FULL" &
|
|
78
81
|
fi
|
package/package.json
CHANGED
package/skills/packs/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: agentcraft-packs
|
|
3
|
-
description: This skill should be used when the user asks to "install a sound pack", "add a pack", "find sound packs", "publish a pack", "create a pack", "share my sounds", "agentcraft pack install", "browse packs", "remove a pack", "update packs", or wants to know how the AgentCraft pack system works.
|
|
3
|
+
description: This skill should be used when the user asks to "install a sound pack", "add a pack", "find sound packs", "publish a pack", "create a pack", "create-pack", "scaffold a pack", "share my sounds", "agentcraft pack install", "browse packs", "remove a pack", "update packs", or wants to know how the AgentCraft pack system works.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# AgentCraft Sound Packs
|
|
@@ -19,6 +19,7 @@ agentcraft list # show installed packs
|
|
|
19
19
|
agentcraft update rohenaz/agentcraft-sounds # git pull one pack
|
|
20
20
|
agentcraft update # update all packs
|
|
21
21
|
agentcraft remove publisher/repo-name # uninstall
|
|
22
|
+
agentcraft create-pack my-sounds # scaffold a new pack repo
|
|
22
23
|
```
|
|
23
24
|
|
|
24
25
|
`agentcraft pack install publisher/repo-name` resolves to `https://github.com/publisher/repo-name` and clones into `~/.agentcraft/packs/publisher/repo-name/`.
|
|
@@ -71,6 +72,14 @@ The hook script resolves this to the absolute path at runtime:
|
|
|
71
72
|
|
|
72
73
|
Any GitHub repo with audio files (`.mp3`, `.wav`, `.ogg`, `.m4a`) is a valid pack. No manifest required — directory structure is the organization.
|
|
73
74
|
|
|
75
|
+
### Step 0: Scaffold with the CLI (fastest path)
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
agentcraft create-pack my-sounds
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
This generates a ready-to-use pack directory with `pack.json`, `README.md`, example directories, and publishing instructions. Then drop your audio files in and push.
|
|
82
|
+
|
|
74
83
|
### Step 1: Organize the repo
|
|
75
84
|
|
|
76
85
|
Recommended structure — group sounds into directories by game, theme, or purpose:
|
|
@@ -122,7 +131,7 @@ The registry GitHub Action runs every 6 hours and automatically picks up newly t
|
|
|
122
131
|
### Step 4: Share the install command
|
|
123
132
|
|
|
124
133
|
```bash
|
|
125
|
-
agentcraft
|
|
134
|
+
agentcraft add your-username/your-repo-name
|
|
126
135
|
```
|
|
127
136
|
|
|
128
137
|
That's the entire publish workflow — push to GitHub, tag it, done.
|
|
@@ -4,13 +4,14 @@ import { resolvePackPath } from '@/lib/packs';
|
|
|
4
4
|
|
|
5
5
|
export async function POST(req: NextRequest) {
|
|
6
6
|
try {
|
|
7
|
-
const { path } = await req.json();
|
|
7
|
+
const { path, volume } = await req.json();
|
|
8
8
|
if (!path || typeof path !== 'string') {
|
|
9
9
|
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
10
10
|
}
|
|
11
11
|
const fullPath = resolvePackPath(path);
|
|
12
12
|
if (!fullPath) return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
13
|
-
|
|
13
|
+
const vol = typeof volume === 'number' && volume >= 0 && volume <= 1 ? volume : 1.0;
|
|
14
|
+
spawn('afplay', ['-v', String(vol), fullPath], { detached: true, stdio: 'ignore' }).unref();
|
|
14
15
|
return NextResponse.json({ ok: true });
|
|
15
16
|
} catch {
|
|
16
17
|
return NextResponse.json({ error: 'Playback failed' }, { status: 500 });
|
package/web/app/page.tsx
CHANGED
|
@@ -79,9 +79,9 @@ export default function Page() {
|
|
|
79
79
|
await fetch('/api/preview', {
|
|
80
80
|
method: 'POST',
|
|
81
81
|
headers: { 'Content-Type': 'application/json' },
|
|
82
|
-
body: JSON.stringify({ path }),
|
|
82
|
+
body: JSON.stringify({ path, volume: assignments.settings.masterVolume }),
|
|
83
83
|
});
|
|
84
|
-
}, []);
|
|
84
|
+
}, [assignments.settings.masterVolume]);
|
|
85
85
|
|
|
86
86
|
const handleSave = useCallback(async () => {
|
|
87
87
|
await fetch('/api/assignments', {
|
|
@@ -100,6 +100,13 @@ export default function Page() {
|
|
|
100
100
|
});
|
|
101
101
|
}, [assignments, handleAssignmentChange]);
|
|
102
102
|
|
|
103
|
+
const handleVolumeChange = useCallback((volume: number) => {
|
|
104
|
+
handleAssignmentChange({
|
|
105
|
+
...assignments,
|
|
106
|
+
settings: { ...assignments.settings, masterVolume: volume },
|
|
107
|
+
});
|
|
108
|
+
}, [assignments, handleAssignmentChange]);
|
|
109
|
+
|
|
103
110
|
const handleUiThemeChange = useCallback((theme: UITheme) => {
|
|
104
111
|
setUITheme(theme, assignments.settings?.uiSounds?.[theme]);
|
|
105
112
|
handleAssignmentChange({
|
|
@@ -227,7 +234,7 @@ export default function Page() {
|
|
|
227
234
|
return (
|
|
228
235
|
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
|
229
236
|
<div className="h-screen w-screen overflow-hidden flex flex-col" style={{ backgroundColor: 'var(--sf-bg)' }}>
|
|
230
|
-
<HudHeader enabled={assignments.settings.enabled} onToggle={handleToggleEnabled} uiTheme={assignments.settings.uiTheme ?? 'sc2'} onUiThemeChange={handleUiThemeChange} onConfigureUISounds={() => setShowUISoundsModal(true)} />
|
|
237
|
+
<HudHeader enabled={assignments.settings.enabled} onToggle={handleToggleEnabled} uiTheme={assignments.settings.uiTheme ?? 'sc2'} onUiThemeChange={handleUiThemeChange} onConfigureUISounds={() => setShowUISoundsModal(true)} masterVolume={assignments.settings.masterVolume ?? 1.0} onVolumeChange={handleVolumeChange} />
|
|
231
238
|
<div className="flex-1 grid overflow-hidden" style={{ gridTemplateColumns: '288px 1fr 320px' }}>
|
|
232
239
|
<AgentRosterPanel
|
|
233
240
|
assignments={assignments}
|
|
@@ -9,6 +9,8 @@ interface HudHeaderProps {
|
|
|
9
9
|
uiTheme: UITheme;
|
|
10
10
|
onUiThemeChange: (theme: UITheme) => void;
|
|
11
11
|
onConfigureUISounds: () => void;
|
|
12
|
+
masterVolume: number;
|
|
13
|
+
onVolumeChange: (v: number) => void;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
const UI_THEMES: { value: UITheme; label: string }[] = [
|
|
@@ -19,7 +21,7 @@ const UI_THEMES: { value: UITheme; label: string }[] = [
|
|
|
19
21
|
{ value: 'off', label: 'OFF' },
|
|
20
22
|
];
|
|
21
23
|
|
|
22
|
-
export function HudHeader({ enabled, onToggle, uiTheme, onUiThemeChange, onConfigureUISounds }: HudHeaderProps) {
|
|
24
|
+
export function HudHeader({ enabled, onToggle, uiTheme, onUiThemeChange, onConfigureUISounds, masterVolume, onVolumeChange }: HudHeaderProps) {
|
|
23
25
|
const [showDropdown, setShowDropdown] = useState(false);
|
|
24
26
|
const activeLabel = UI_THEMES.find((t) => t.value === uiTheme)?.label ?? uiTheme.toUpperCase();
|
|
25
27
|
|
|
@@ -116,6 +118,26 @@ export function HudHeader({ enabled, onToggle, uiTheme, onUiThemeChange, onConfi
|
|
|
116
118
|
|
|
117
119
|
<div className="h-4 w-px opacity-20" style={{ backgroundColor: 'var(--sf-cyan)' }} />
|
|
118
120
|
|
|
121
|
+
{/* Master volume */}
|
|
122
|
+
<div className="flex items-center gap-1.5">
|
|
123
|
+
<span className="text-[10px] tracking-widest uppercase opacity-40">VOL</span>
|
|
124
|
+
<input
|
|
125
|
+
type="range"
|
|
126
|
+
min={0}
|
|
127
|
+
max={100}
|
|
128
|
+
value={Math.round(masterVolume * 100)}
|
|
129
|
+
onChange={(e) => onVolumeChange(Number(e.target.value) / 100)}
|
|
130
|
+
data-no-ui-sound
|
|
131
|
+
className="w-16"
|
|
132
|
+
style={{ cursor: 'pointer', accentColor: 'var(--sf-cyan)', verticalAlign: 'middle' }}
|
|
133
|
+
/>
|
|
134
|
+
<span className="text-[10px] w-7 text-right tabular-nums" style={{ color: 'var(--sf-cyan)', opacity: 0.7 }}>
|
|
135
|
+
{Math.round(masterVolume * 100)}%
|
|
136
|
+
</span>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div className="h-4 w-px opacity-20" style={{ backgroundColor: 'var(--sf-cyan)' }} />
|
|
140
|
+
|
|
119
141
|
{/* Master enable/disable */}
|
|
120
142
|
<button
|
|
121
143
|
onClick={onToggle}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useMemo, useCallback } from 'react';
|
|
3
|
+
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
4
4
|
import { SoundUnit } from './sound-unit';
|
|
5
5
|
import { groupSoundsByCategory, getGroupLabel, getSubTabLabel } from '@/lib/utils';
|
|
6
6
|
import { playUISound } from '@/lib/ui-audio';
|
|
@@ -21,30 +21,53 @@ function internalCat(category: string): string {
|
|
|
21
21
|
return idx === -1 ? category : category.slice(idx + 1);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Extract pack ID: "publisher/name:sc2/terran" → "publisher/name"
|
|
25
|
+
function packOfCat(category: string): string {
|
|
26
|
+
const idx = category.indexOf(':');
|
|
27
|
+
return idx === -1 ? '' : category.slice(0, idx);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// "publisher/name" → "name"
|
|
31
|
+
function packShortName(packId: string): string {
|
|
32
|
+
return packId.split('/')[1] ?? packId;
|
|
33
|
+
}
|
|
34
|
+
|
|
24
35
|
export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode, onSelectModeAssign, onClearSelectMode }: SoundBrowserPanelProps) {
|
|
36
|
+
const [activePack, setActivePack] = useState<string | null>(null);
|
|
25
37
|
const [activeGroup, setActiveGroup] = useState<string>('sc2');
|
|
26
38
|
const [activeCategory, setActiveCategory] = useState<string>('sc2/terran');
|
|
27
39
|
const [search, setSearch] = useState('');
|
|
28
40
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}, []);
|
|
41
|
+
// All unique pack IDs present in the library
|
|
42
|
+
const allPacks = useMemo(() => {
|
|
43
|
+
return [...new Set(sounds.map((s) => packOfCat(s.category)))].filter(Boolean).sort();
|
|
44
|
+
}, [sounds]);
|
|
33
45
|
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
const showPackSelector = allPacks.length > 1;
|
|
47
|
+
|
|
48
|
+
// Sounds visible given the current pack filter
|
|
49
|
+
const visibleSounds = useMemo(() => {
|
|
50
|
+
if (!activePack) return sounds;
|
|
51
|
+
const prefix = activePack + ':';
|
|
52
|
+
return sounds.filter((s) => s.category.startsWith(prefix));
|
|
53
|
+
}, [sounds, activePack]);
|
|
38
54
|
|
|
39
55
|
const allGroups = useMemo(() => {
|
|
40
|
-
return [...new Set(
|
|
41
|
-
}, [
|
|
56
|
+
return [...new Set(visibleSounds.map((s) => internalCat(s.category).split('/')[0]))].sort();
|
|
57
|
+
}, [visibleSounds]);
|
|
58
|
+
|
|
59
|
+
// If activeGroup disappears after a pack switch, reset to the first available group
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (allGroups.length > 0 && !allGroups.includes(activeGroup)) {
|
|
62
|
+
setActiveGroup(allGroups[0]);
|
|
63
|
+
}
|
|
64
|
+
}, [allGroups, activeGroup]);
|
|
42
65
|
|
|
43
66
|
const groupCategories = useMemo(() => {
|
|
44
67
|
return [...new Set(
|
|
45
|
-
|
|
68
|
+
visibleSounds.filter((s) => internalCat(s.category).split('/')[0] === activeGroup).map((s) => s.category)
|
|
46
69
|
)].sort();
|
|
47
|
-
}, [
|
|
70
|
+
}, [visibleSounds, activeGroup]);
|
|
48
71
|
|
|
49
72
|
// If activeCategory doesn't belong to current group, use first category of the group
|
|
50
73
|
const effectiveCategory = groupCategories.includes(activeCategory)
|
|
@@ -64,21 +87,36 @@ export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode,
|
|
|
64
87
|
|
|
65
88
|
const filteredSounds = useMemo(() => {
|
|
66
89
|
if (isSearching) {
|
|
67
|
-
// Global search across all sounds
|
|
68
90
|
const q = search.toLowerCase();
|
|
69
|
-
return
|
|
91
|
+
return visibleSounds.filter((s) =>
|
|
70
92
|
s.filename.toLowerCase().includes(q) ||
|
|
71
93
|
s.category.toLowerCase().includes(q) ||
|
|
72
94
|
s.subcategory.toLowerCase().includes(q)
|
|
73
95
|
);
|
|
74
96
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}, [sounds, effectiveCategory, search, isSearching]);
|
|
97
|
+
return visibleSounds.filter((s) => s.category === effectiveCategory);
|
|
98
|
+
}, [visibleSounds, effectiveCategory, search, isSearching]);
|
|
78
99
|
|
|
79
100
|
const grouped = useMemo(() => groupSoundsByCategory(filteredSounds), [filteredSounds]);
|
|
80
101
|
|
|
81
102
|
const showSubTabs = !isSearching && groupCategories.length > 1;
|
|
103
|
+
// Show pack label on cards when browsing all packs and multiple packs are installed
|
|
104
|
+
const showPackBadge = showPackSelector && !activePack;
|
|
105
|
+
|
|
106
|
+
const handleGroupChange = useCallback((group: string) => {
|
|
107
|
+
setActiveGroup(group);
|
|
108
|
+
playUISound('pageChange', 0.4);
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
const handleCategoryChange = useCallback((cat: string) => {
|
|
112
|
+
setActiveCategory(cat);
|
|
113
|
+
playUISound('pageChange', 0.35);
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
const handlePackChange = useCallback((pack: string | null) => {
|
|
117
|
+
setActivePack(pack);
|
|
118
|
+
playUISound('pageChange', 0.4);
|
|
119
|
+
}, []);
|
|
82
120
|
|
|
83
121
|
return (
|
|
84
122
|
<div className="flex flex-col overflow-hidden" style={{ borderLeft: '1px solid var(--sf-border)', borderRight: '1px solid var(--sf-border)' }}>
|
|
@@ -103,6 +141,45 @@ export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode,
|
|
|
103
141
|
}}
|
|
104
142
|
/>
|
|
105
143
|
|
|
144
|
+
{/* SOURCE pack selector — only shown when 2+ packs installed */}
|
|
145
|
+
{showPackSelector && (
|
|
146
|
+
<div
|
|
147
|
+
className="flex items-center gap-1 mt-2 overflow-x-auto"
|
|
148
|
+
style={{ opacity: isSearching ? 0.3 : 1, pointerEvents: isSearching ? 'none' : 'auto' }}
|
|
149
|
+
>
|
|
150
|
+
<span className="text-[9px] tracking-widest uppercase shrink-0" style={{ color: 'var(--sf-gold)', opacity: 0.6 }}>
|
|
151
|
+
SOURCE
|
|
152
|
+
</span>
|
|
153
|
+
<button
|
|
154
|
+
data-sf-hover
|
|
155
|
+
onClick={() => handlePackChange(null)}
|
|
156
|
+
className="shrink-0 px-2 py-0.5 text-[9px] sf-heading font-semibold uppercase tracking-wider transition-all"
|
|
157
|
+
style={{
|
|
158
|
+
border: `1px solid ${!activePack ? 'var(--sf-gold)' : 'rgba(255,192,0,0.2)'}`,
|
|
159
|
+
color: !activePack ? 'var(--sf-gold)' : 'rgba(255,192,0,0.45)',
|
|
160
|
+
backgroundColor: !activePack ? 'rgba(255,192,0,0.08)' : 'transparent',
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
ALL
|
|
164
|
+
</button>
|
|
165
|
+
{allPacks.map((id) => (
|
|
166
|
+
<button
|
|
167
|
+
key={id}
|
|
168
|
+
data-sf-hover
|
|
169
|
+
onClick={() => handlePackChange(id)}
|
|
170
|
+
className="shrink-0 px-2 py-0.5 text-[9px] sf-heading font-semibold uppercase tracking-wider transition-all"
|
|
171
|
+
style={{
|
|
172
|
+
border: `1px solid ${activePack === id ? 'var(--sf-gold)' : 'rgba(255,192,0,0.2)'}`,
|
|
173
|
+
color: activePack === id ? 'var(--sf-gold)' : 'rgba(255,192,0,0.45)',
|
|
174
|
+
backgroundColor: activePack === id ? 'rgba(255,192,0,0.08)' : 'transparent',
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
{packShortName(id)}
|
|
178
|
+
</button>
|
|
179
|
+
))}
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
|
|
106
183
|
{/* Group tabs */}
|
|
107
184
|
<div className="flex gap-1 mt-2 overflow-x-auto" style={{ opacity: isSearching ? 0.3 : 1, pointerEvents: isSearching ? 'none' : 'auto' }}>
|
|
108
185
|
{allGroups.map((group) => (
|
|
@@ -196,6 +273,7 @@ export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode,
|
|
|
196
273
|
isAssigned={assignedPaths.has(sound.path)}
|
|
197
274
|
onPreview={onPreview}
|
|
198
275
|
onSelectAssign={selectMode ? () => onSelectModeAssign(sound.path) : undefined}
|
|
276
|
+
packLabel={showPackBadge ? packShortName(packOfCat(sound.category)) : undefined}
|
|
199
277
|
/>
|
|
200
278
|
))}
|
|
201
279
|
</div>
|
|
@@ -11,11 +11,12 @@ interface SoundUnitProps {
|
|
|
11
11
|
onPreview: (path: string) => void;
|
|
12
12
|
isOverlay?: boolean;
|
|
13
13
|
onSelectAssign?: () => void; // if set, card click assigns instead of plays
|
|
14
|
+
packLabel?: string; // shown when browsing multiple packs simultaneously
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const BARS = 16;
|
|
17
18
|
|
|
18
|
-
export function SoundUnit({ sound, isAssigned, onPreview, isOverlay, onSelectAssign }: SoundUnitProps) {
|
|
19
|
+
export function SoundUnit({ sound, isAssigned, onPreview, isOverlay, onSelectAssign, packLabel }: SoundUnitProps) {
|
|
19
20
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
20
21
|
const [isHovered, setIsHovered] = useState(false);
|
|
21
22
|
const [bars, setBars] = useState<number[]>(sound.waveform.map(h => h / 10));
|
|
@@ -198,6 +199,11 @@ export function SoundUnit({ sound, isAssigned, onPreview, isOverlay, onSelectAss
|
|
|
198
199
|
<span className="shrink-0 text-[9px] animate-pulse" style={{ color: 'var(--sf-cyan)' }}>♪</span>
|
|
199
200
|
)}
|
|
200
201
|
</div>
|
|
202
|
+
{packLabel && (
|
|
203
|
+
<span className="text-[8px] truncate leading-none" style={{ color: 'rgba(255,192,0,0.45)' }}>
|
|
204
|
+
{packLabel}
|
|
205
|
+
</span>
|
|
206
|
+
)}
|
|
201
207
|
</div>
|
|
202
208
|
);
|
|
203
209
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, useCallback } from 'react';
|
|
3
|
+
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
4
4
|
import { previewUISound } from '@/lib/ui-audio';
|
|
5
5
|
import type { UITheme, UISlotMap } from '@/lib/types';
|
|
6
6
|
|
|
@@ -12,6 +12,8 @@ interface UISound {
|
|
|
12
12
|
|
|
13
13
|
type SlotName = 'click' | 'hover' | 'error' | 'pageChange' | 'toggle' | 'confirm';
|
|
14
14
|
|
|
15
|
+
const CANONICAL_SLOTS = new Set(['click', 'hover', 'error', 'pageChange', 'toggle', 'confirm']);
|
|
16
|
+
|
|
15
17
|
const SLOTS: { name: SlotName; label: string; desc: string }[] = [
|
|
16
18
|
{ name: 'hover', label: 'HOVER', desc: 'Mouse over interactive element' },
|
|
17
19
|
{ name: 'click', label: 'CLICK', desc: 'Button or card clicked' },
|
|
@@ -30,8 +32,8 @@ interface Props {
|
|
|
30
32
|
|
|
31
33
|
export function UISoundsModal({ uiTheme, uiSounds, onSave, onClose }: Props) {
|
|
32
34
|
const [sounds, setSounds] = useState<UISound[]>([]);
|
|
33
|
-
const [activeTheme, setActiveTheme] = useState<
|
|
34
|
-
const [slots, setSlots] = useState<UISlotMap>(uiSounds[uiTheme === 'off' ? '
|
|
35
|
+
const [activeTheme, setActiveTheme] = useState<string>(uiTheme === 'off' ? '' : uiTheme);
|
|
36
|
+
const [slots, setSlots] = useState<UISlotMap>(uiSounds[uiTheme === 'off' ? '' : uiTheme] ?? {});
|
|
35
37
|
const [activeSlot, setActiveSlot] = useState<SlotName>('hover');
|
|
36
38
|
const [playing, setPlaying] = useState<string | null>(null);
|
|
37
39
|
|
|
@@ -39,8 +41,32 @@ export function UISoundsModal({ uiTheme, uiSounds, onSave, onClose }: Props) {
|
|
|
39
41
|
fetch('/api/ui-sounds').then((r) => r.json()).then(setSounds).catch(console.error);
|
|
40
42
|
}, []);
|
|
41
43
|
|
|
44
|
+
// Derive theme tabs: a ui/ subdir qualifies as a theme only if it contains
|
|
45
|
+
// at least one file whose basename matches a canonical slot name.
|
|
46
|
+
// Folders starting with '_' are always excluded (utility library escape hatch).
|
|
47
|
+
const themes = useMemo(() => {
|
|
48
|
+
const validGroups = new Set(
|
|
49
|
+
sounds
|
|
50
|
+
.filter((s) => {
|
|
51
|
+
const base = s.filename.replace(/\.(mp3|wav|ogg|m4a)$/i, '');
|
|
52
|
+
return CANONICAL_SLOTS.has(base);
|
|
53
|
+
})
|
|
54
|
+
.map((s) => s.group)
|
|
55
|
+
);
|
|
56
|
+
return [...validGroups].filter((t) => t && !t.startsWith('_')).sort();
|
|
57
|
+
}, [sounds]);
|
|
58
|
+
|
|
59
|
+
// When themes load, initialize activeTheme to first available if current isn't in list
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (themes.length > 0 && activeTheme === '') {
|
|
62
|
+
const initial = themes[0];
|
|
63
|
+
setActiveTheme(initial);
|
|
64
|
+
setSlots(uiSounds[initial] ?? {});
|
|
65
|
+
}
|
|
66
|
+
}, [themes, activeTheme, uiSounds]);
|
|
67
|
+
|
|
42
68
|
// When theme changes, load existing slot config for that theme
|
|
43
|
-
const switchTheme = useCallback((theme:
|
|
69
|
+
const switchTheme = useCallback((theme: string) => {
|
|
44
70
|
setActiveTheme(theme);
|
|
45
71
|
setSlots(uiSounds[theme] ?? {});
|
|
46
72
|
}, [uiSounds]);
|
|
@@ -110,14 +136,14 @@ export function UISoundsModal({ uiTheme, uiSounds, onSave, onClose }: Props) {
|
|
|
110
136
|
</div>
|
|
111
137
|
|
|
112
138
|
{/* Theme selector */}
|
|
113
|
-
<div className="flex items-center gap-2 px-5 py-2 shrink-0" style={{ borderBottom: '1px solid var(--sf-border)' }}>
|
|
114
|
-
<span className="text-[10px] tracking-widest uppercase opacity-40">THEME</span>
|
|
115
|
-
{
|
|
139
|
+
<div className="flex items-center gap-2 px-5 py-2 shrink-0 overflow-x-auto" style={{ borderBottom: '1px solid var(--sf-border)' }}>
|
|
140
|
+
<span className="text-[10px] tracking-widest uppercase opacity-40 shrink-0">THEME</span>
|
|
141
|
+
{themes.map((t) => (
|
|
116
142
|
<button
|
|
117
143
|
key={t}
|
|
118
144
|
data-sf-hover
|
|
119
145
|
onClick={() => switchTheme(t)}
|
|
120
|
-
className="px-3 py-0.5 text-[10px] sf-heading font-semibold uppercase tracking-wider transition-all"
|
|
146
|
+
className="shrink-0 px-3 py-0.5 text-[10px] sf-heading font-semibold uppercase tracking-wider transition-all"
|
|
121
147
|
style={{
|
|
122
148
|
border: `1px solid ${activeTheme === t ? 'var(--sf-cyan)' : 'var(--sf-border)'}`,
|
|
123
149
|
color: activeTheme === t ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.35)',
|
package/web/lib/types.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface SkillConfig {
|
|
|
21
21
|
hooks: Partial<Record<SkillHookEvent, string>>;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export type UITheme =
|
|
24
|
+
export type UITheme = string; // Dynamic: derives from installed pack ui/ directory names; 'off' disables UI sounds
|
|
25
25
|
|
|
26
26
|
export interface SelectMode {
|
|
27
27
|
scope: string; // 'global' | agent-name | 'skill/qualifiedName'
|