feat: card-based Sample Library layout with grouping #15

Merged
david merged 3 commits from feat/sample-library-cards into main 2026-06-22 09:56:57 +00:00
8 changed files with 373 additions and 53 deletions
+2
View File
@@ -30,3 +30,5 @@ web/ui/node_modules/
# Temp # Temp
/tmp/ /tmp/
akai-fetch.test
cover.out
+20 -7
View File
@@ -16,7 +16,9 @@ mise run serve # → starts HTTP server, opens browser
mise run electron-deps # installs electron + electron-builder mise run electron-deps # installs electron + electron-builder
mise run electron-dev # runs electron in dev mode mise run electron-dev # runs electron in dev mode
mise run docker-up # docker compose up --build mise run docker-up # docker compose up --build
mise run lint # go vet ./... mise run lint # golangci-lint run (Go)
mise run js-lint # eslint (JS)
mise run js-test # node --test (JS)
mise run check-all # verify gcc, brew, mise, go, node, docker, colima mise run check-all # verify gcc, brew, mise, go, node, docker, colima
``` ```
@@ -55,7 +57,14 @@ Single binary (`fetch`) with subcommands. No external Go dependencies — pure s
``` ```
main.go CLI entry, commands (search/list/download/extract/pipeline/serve) main.go CLI entry, commands (search/list/download/extract/pipeline/serve)
serve.go HTTP server + REST API + embedded web UI (embed.FS) serve.go HTTP server + REST API + embedded web UI (embed.FS)
web/ui/ Static frontend (index.html, style.css, app.js) web/ui/ Static frontend
├── index.html HTML layout
├── style.css CSS custom properties theme
├── util.js shared utility functions
├── app.js application logic, event delegation
├── util.test.js JS tests (node --test)
├── eslint.config.js ESLint flat config
└── package.json ESLint + test scripts
electron/ Electron wrapper shell electron/ Electron wrapper shell
scripts/ Bash helpers called via exec scripts/ Bash helpers called via exec
├── extract_wavs.sh batch WAV extraction via akaiutil ├── extract_wavs.sh batch WAV extraction via akaiutil
@@ -70,8 +79,9 @@ third_party/ Vendored akaiutil C binary (GPLv2)
- **Flag parsing**: each command creates its own `flag.NewFlagSet` — avoids global flag state - **Flag parsing**: each command creates its own `flag.NewFlagSet` — avoids global flag state
- **Concurrency**: semaphore channel pattern (`sem := make(chan struct{}, N)`) for bounded parallelism - **Concurrency**: semaphore channel pattern (`sem := make(chan struct{}, N)`) for bounded parallelism
- **SSE progress**: `serve.go:handleProgress` — 500ms ticker, EventSource stream, auto-closes when all done - **SSE progress**: `serve.go:handleProgress` — 500ms ticker, EventSource stream, auto-closes when all done
- **Event delegation**: `app.js` — single click listener on document, dispatches by `[data-action]` attribute (download, list, play)
- **akaiutil wrapper**: `extract.go` — persistent stdin/stdout pipes, no bash intermediary - **akaiutil wrapper**: `extract.go` — persistent stdin/stdout pipes, no bash intermediary
- **Zero-deps web UI**: vanilla JS, no bundler, no framework. API calls use `fetch()` + `EventSource` - **Zero-deps web UI**: vanilla JS, no bundler, no framework. API calls use `fetch()` + `EventSource`. Web Audio API for WAV playback.
- **Embed directive**: `//go:embed web/ui/*` in `serve.go` — frontend baked into the binary - **Embed directive**: `//go:embed web/ui/*` in `serve.go` — frontend baked into the binary
- **Bind address handling**: `serve.go:37``--host` flag defaults to `127.0.0.1` for local dev; set `--host 0.0.0.0` for Docker. When binding to `0.0.0.0`, prints both `localhost` and `<host-ip>` URLs for clarity. - **Bind address handling**: `serve.go:37``--host` flag defaults to `127.0.0.1` for local dev; set `--host 0.0.0.0` for Docker. When binding to `0.0.0.0`, prints both `localhost` and `<host-ip>` URLs for clarity.
@@ -124,12 +134,15 @@ All akaiutil calls use `-r` (read-only) flag. Write operations (S900 compress, p
## Testing ## Testing
No test framework yet. Manual verification:
```bash ```bash
go build -o /dev/null . # compile check # Go
go test -cover # unit tests (extract, serve, http, download, cli, util)
go vet ./... # static analysis go vet ./... # static analysis
golangci-lint run # comprehensive lint golangci-lint run # comprehensive lint (uses .golangci.yml)
./akai-fetch serve # manual smoke test
# JavaScript
mise run js-test # node --test (19 tests: esc, sanitize, formatBytes, formatTime)
mise run js-lint # eslint (flat config)
``` ```
## Secrets & Tokens ## Secrets & Tokens
+1 -1
View File
@@ -110,7 +110,7 @@ See [open issues](https://git.notsosm.art/david/akai-utils/issues).
| #6 | Direct-to-Disk Take Extraction | todo | | #6 | Direct-to-Disk Take Extraction | todo |
| #7 | TAR Bulk Import/Export | todo | | #7 | TAR Bulk Import/Export | todo |
| #8 | Sample Tag Editor | todo | | #8 | Sample Tag Editor | todo |
| #9 | Audio Preview Player | todo | | #9 | Audio Preview Player | done |
## License ## License
+78 -17
View File
@@ -200,42 +200,85 @@ let animationFrame = null;
document.getElementById('wav-browse-btn').addEventListener('click', browseWavs); document.getElementById('wav-browse-btn').addEventListener('click', browseWavs);
function renderSampleCards(samples) {
return samples.map(s => `
<div class="sample-card" data-action="play" data-wav="${esc(s.fullPath)}">
<span class="sample-name" title="${esc(s.name)}">${esc(s.name)}</span>
</div>
`).join('');
}
function renderPrograms(programs) {
const progNames = Object.keys(programs).sort();
return progNames.map(prog => {
const samples = programs[prog];
return `
<div class="program-card">
<div class="program-header">
<h4>${esc(cleanName(prog))}</h4>
<span class="prog-meta">${samples.length} samples</span>
</div>
<div class="program-samples">
${renderSampleCards(samples)}
</div>
</div>`;
}).join('');
}
function renderLibraries(libs) {
const libNames = Object.keys(libs).sort();
return libNames.map(lib => {
const programs = libs[lib];
const progCount = Object.keys(programs).length;
const sampleCount = Object.values(programs).reduce((sum, s) => sum + s.length, 0);
return `
<div class="library-card">
<div class="library-header" onclick="this.parentNode.classList.toggle('collapsed')">
<h3>${esc(cleanName(lib))}<span class="lib-toggle">▾</span></h3>
<span class="lib-meta">${progCount} programs · ${sampleCount} samples</span>
</div>
<div class="library-programs">
${renderPrograms(programs)}
</div>
</div>`;
}).join('');
}
async function browseWavs() { async function browseWavs() {
const dir = document.getElementById('wav-dir').value; const dir = document.getElementById('wav-dir').value;
const results = document.getElementById('wav-results');
const summary = document.getElementById('wav-summary');
const list = document.getElementById('wav-list'); const list = document.getElementById('wav-list');
list.innerHTML = 'Loading...'; list.innerHTML = 'Loading...';
console.log('browseWavs: dir =', dir);
try { try {
const url = `${API}/list-wavs?dir=${encodeURIComponent(dir)}`; const url = `${API}/list-wavs?dir=${encodeURIComponent(dir)}`;
console.log('browseWavs: fetching', url);
const resp = await fetch(url); const resp = await fetch(url);
console.log('browseWavs: response status', resp.status, 'ok', resp.ok);
if (!resp.ok) { if (!resp.ok) {
list.innerHTML = '<span class="no-files">HTTP ' + resp.status + '</span>'; list.innerHTML = '<span class="no-files">HTTP ' + resp.status + '</span>';
results.style.display = 'block';
return; return;
} }
const data = await resp.json(); const data = await resp.json();
console.log('browseWavs: parsed data', data, 'type', typeof data, 'isArray', Array.isArray(data));
if (!data || data.error) { if (!data || data.error) {
list.innerHTML = '<span class="no-files">Error: ' + (data && data.error ? data.error : 'server error') + '</span>'; list.innerHTML = '<span class="no-files">Error: ' + (data && data.error ? data.error : 'server error') + '</span>';
results.style.display = 'block';
return; return;
} }
if (!Array.isArray(data) || data.length === 0) { if (!Array.isArray(data) || data.length === 0) {
list.innerHTML = '<span class="no-files">No WAV files found</span>'; list.innerHTML = '<span class="no-files">No WAV files found</span>';
results.style.display = 'block';
return; return;
} }
console.log('browseWavs: found', data.length, 'wavs'); const libs = groupWavs(data);
list.innerHTML = data.map(f => ` const libCount = Object.keys(libs).length;
<div class="wav-item" data-file="${esc(f)}"> summary.textContent = libCount + ' librar' + (libCount === 1 ? 'y' : 'ies') + ' · ' + data.length + ' samples';
<span class="wav-name" title="${esc(f)}">${esc(f)}</span> list.innerHTML = renderLibraries(libs);
<button class="wav-play-btn" data-action="play" data-wav="${esc(f)}">Play</button> results.style.display = 'block';
</div>
`).join('');
} catch (e) { } catch (e) {
console.error('browseWavs: error', e);
list.innerHTML = 'Failed: ' + e.message; list.innerHTML = 'Failed: ' + e.message;
results.style.display = 'block';
} }
} }
@@ -255,8 +298,11 @@ async function playWav(filename) {
animationFrame = null; animationFrame = null;
} }
const name = filename.split('/').pop().replace(/\.wav$/i, '');
const player = document.getElementById('wav-player');
const nowPlaying = document.getElementById('wav-now-playing'); const nowPlaying = document.getElementById('wav-now-playing');
nowPlaying.textContent = 'Loading: ' + filename; player.style.display = 'block';
nowPlaying.textContent = 'Loading: ' + name;
try { try {
const resp = await fetch(`/wavs/${encodeURIComponent(filename)}`); const resp = await fetch(`/wavs/${encodeURIComponent(filename)}`);
@@ -276,6 +322,7 @@ async function playWav(filename) {
document.getElementById('wav-progress-fill').style.width = '0%'; document.getElementById('wav-progress-fill').style.width = '0%';
document.getElementById('wav-time').textContent = formatTime(0); document.getElementById('wav-time').textContent = formatTime(0);
clearActiveItem(); clearActiveItem();
player.style.display = 'none';
} }
}; };
@@ -284,7 +331,7 @@ async function playWav(filename) {
isPlaying = true; isPlaying = true;
pauseOffset = 0; pauseOffset = 0;
nowPlaying.textContent = '▶ ' + filename; nowPlaying.textContent = '▶ ' + name;
setActiveItem(filename); setActiveItem(filename);
updateProgress(); updateProgress();
} catch (e) { } catch (e) {
@@ -308,13 +355,13 @@ function updateProgress() {
} }
function setActiveItem(filename) { function setActiveItem(filename) {
document.querySelectorAll('.wav-item').forEach(el => { document.querySelectorAll('.sample-card').forEach(el => {
el.classList.toggle('playing', el.dataset.file === filename); el.classList.toggle('playing', el.dataset.wav === filename);
}); });
} }
function clearActiveItem() { function clearActiveItem() {
document.querySelectorAll('.wav-item').forEach(el => { document.querySelectorAll('.sample-card').forEach(el => {
el.classList.remove('playing'); el.classList.remove('playing');
}); });
} }
@@ -335,3 +382,17 @@ document.addEventListener('click', function(e) {
playWav(btn.dataset.wav); playWav(btn.dataset.wav);
} }
}); });
// Section collapse toggle
document.querySelectorAll('.section-toggle').forEach(h => {
h.addEventListener('click', function () {
this.classList.toggle('collapsed');
const target = document.getElementById(this.dataset.section);
target.style.display = target.style.display === 'none' ? '' : 'none';
});
});
// Collapse all libraries
document.getElementById('wav-collapse-all').addEventListener('click', function () {
document.querySelectorAll('.library-card').forEach(c => c.classList.add('collapsed'));
});
+15 -7
View File
@@ -40,18 +40,26 @@
</section> </section>
<section id="wav-section"> <section id="wav-section">
<h2>WAV Browser</h2> <h2 class="section-toggle" data-section="wav-body">Sample Library <span class="toggle-icon"></span></h2>
<div id="wav-body">
<div class="wav-controls"> <div class="wav-controls">
<input type="text" id="wav-dir" placeholder="WAV directory" value="./output/wavs"> <input type="text" id="wav-dir" placeholder="WAV directory" value="./output/wavs">
<button id="wav-browse-btn">Browse</button> <button id="wav-browse-btn">Browse</button>
</div> </div>
<div id="wav-player" class="wav-player"> <div id="wav-results" style="display:none">
<div id="wav-now-playing" class="now-playing"></div> <div class="wav-summary-row">
<div class="audio-bar"> <div id="wav-summary" class="wav-summary"></div>
<div class="audio-progress"><div id="wav-progress-fill" class="audio-progress-fill"></div></div> <button id="wav-collapse-all" class="collapse-all-btn">Collapse All</button>
<span id="wav-time">0:00</span>
</div> </div>
<div id="wav-list" class="wav-list"></div> <div id="wav-player" class="wav-player" style="display:none">
<div id="wav-now-playing" class="now-playing"></div>
<div class="audio-bar">
<div class="audio-progress"><div id="wav-progress-fill" class="audio-progress-fill"></div></div>
<span id="wav-time">0:00</span>
</div>
</div>
<div id="wav-list" class="wav-library"></div>
</div>
</div> </div>
</section> </section>
+162 -19
View File
@@ -50,6 +50,23 @@ section {
margin-bottom: 20px; margin-bottom: 20px;
} }
/* Section collapse toggle */
.section-toggle {
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
}
.toggle-icon {
font-size: 12px;
transition: transform 0.2s;
display: inline-block;
}
.section-toggle.collapsed .toggle-icon {
transform: rotate(-90deg);
}
h2 { h2 {
font-size: 16px; font-size: 16px;
color: var(--text2); color: var(--text2);
@@ -272,42 +289,168 @@ h2 {
min-width: 40px; min-width: 40px;
} }
.wav-list { .no-files {
display: grid; color: var(--text2);
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); font-size: 13px;
gap: 8px; padding: 12px 0;
} }
.wav-item { .wav-summary {
background: var(--bg); font-size: 13px;
color: var(--text2);
margin-top: 12px;
margin-bottom: 12px;
min-height: 18px;
}
/* Library cards */
.wav-library {
display: flex;
flex-direction: column;
gap: 12px;
}
.library-card {
background: var(--bg2);
border: 1px solid var(--bg3); border: 1px solid var(--bg3);
border-radius: var(--radius); border-radius: var(--radius);
padding: 10px 12px; overflow: hidden;
margin-bottom: 8px;
}
.library-header {
padding: 10px 16px;
cursor: pointer;
user-select: none;
background: var(--bg);
border-bottom: 1px solid var(--bg3);
transition: border-color 0.15s;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 12px;
cursor: pointer;
} }
.wav-item:hover { .library-header:hover {
border-color: var(--accent); border-color: var(--accent);
} }
.wav-item.playing { .library-header h3 {
border-color: var(--green); font-size: 14px;
background: rgba(78, 204, 163, 0.1); font-weight: 600;
color: var(--text);
margin: 0;
text-transform: none;
letter-spacing: 0;
display: flex;
align-items: center;
gap: 4px;
} }
.wav-item .wav-name { .library-header .lib-meta {
flex: 1; font-size: 11px;
font-size: 13px; color: var(--text2);
margin-left: auto;
}
.library-programs {
padding: 8px 16px 12px;
}
/* Program cards */
.program-card {
margin-top: 10px;
}
.program-card:first-child {
margin-top: 0;
}
.program-header {
cursor: default;
user-select: none;
margin-bottom: 6px;
}
.program-header h4 {
font-size: 12px;
font-weight: 600;
color: var(--accent);
margin: 0;
text-transform: none;
letter-spacing: 0;
display: inline;
}
.program-header .prog-meta {
font-size: 10px;
color: var(--text2);
margin-left: 6px;
}
.program-samples {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
gap: 4px;
}
/* Sample cards */
.sample-card {
background: var(--bg);
border: 1px solid var(--bg3);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
min-height: 28px;
transition: border-color 0.15s, background 0.15s;
}
.sample-card:hover {
border-color: var(--accent);
}
.sample-card.playing {
border-color: var(--green);
background: rgba(78, 204, 163, 0.15);
}
.sample-card .sample-name {
font-size: 10px;
color: var(--text);
text-align: center;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: 100%;
} }
.wav-item .wav-play-btn { .sample-card .sample-play-icon {
background: var(--accent2); display: none;
color: white; }
/* Collapse all button */
.wav-summary-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.collapse-all-btn {
background: var(--bg3);
color: var(--text2);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
padding: 4px 10px; padding: 4px 10px;
font-size: 11px; font-size: 11px;
cursor: pointer; cursor: pointer;
white-space: nowrap;
}
.collapse-all-btn:hover {
color: var(--text);
}
/* Library collapse state */
.library-card.collapsed .library-programs {
display: none;
}
.library-header .lib-toggle {
font-size: 11px;
transition: transform 0.2s;
margin-left: 8px;
color: var(--text2);
}
.library-card.collapsed .lib-toggle {
transform: rotate(-90deg);
} }
+24 -1
View File
@@ -24,6 +24,29 @@ function formatTime(secs) {
return m + ':' + s.toString().padStart(2, '0') return m + ':' + s.toString().padStart(2, '0')
} }
function groupWavs(paths) {
const libs = {}
for (const p of paths) {
const parts = p.split('/')
let lib, prog, file
if (parts.length === 1) {
lib = ''; prog = parts[0]; file = parts[0]
} else if (parts.length === 2) {
lib = parts[0]; prog = parts[0]; file = parts[1]
} else {
lib = parts[0]; prog = parts[1]; file = parts[parts.length - 1]
}
if (!libs[lib]) libs[lib] = {}
if (!libs[lib][prog]) libs[lib][prog] = []
libs[lib][prog].push({ fullPath: p, name: file.replace(/\.wav$/i, '') })
}
return libs
}
function cleanName(name) {
return name.replace(/_+/g, ' ').trim()
}
if (typeof module !== 'undefined' && module.exports) { if (typeof module !== 'undefined' && module.exports) {
module.exports = { esc, sanitize, formatBytes, formatTime } module.exports = { esc, sanitize, formatBytes, formatTime, groupWavs, cleanName }
} }
+71 -1
View File
@@ -1,6 +1,6 @@
const { describe, it } = require('node:test') const { describe, it } = require('node:test')
const assert = require('node:assert/strict') const assert = require('node:assert/strict')
const { esc, sanitize, formatBytes, formatTime } = require('./util.js') const { esc, sanitize, formatBytes, formatTime, groupWavs, cleanName } = require('./util.js')
describe('esc', () => { describe('esc', () => {
it('escapes HTML entities', () => { it('escapes HTML entities', () => {
@@ -88,4 +88,74 @@ describe('formatTime', () => {
it('formats large times', () => { it('formats large times', () => {
assert.strictEqual(formatTime(3661), '61:01') assert.strictEqual(formatTime(3661), '61:01')
}) })
})
describe('groupWavs', () => {
it('groups by library and program', () => {
const paths = [
'LibA/Prog1/kick.wav',
'LibA/Prog1/snare.wav',
'LibA/Prog2/hat.wav'
]
const result = groupWavs(paths)
assert.strictEqual(Object.keys(result).length, 1)
assert.strictEqual(Object.keys(result.LibA).length, 2)
assert.strictEqual(result.LibA.Prog1.length, 2)
assert.strictEqual(result.LibA.Prog2.length, 1)
})
it('handles multiple libraries', () => {
const paths = [
'LibA/kick.wav',
'LibB/snare.wav'
]
const result = groupWavs(paths)
assert.strictEqual(Object.keys(result).length, 2)
assert.strictEqual(result.LibA.LibA[0].name, 'kick')
assert.strictEqual(result.LibB.LibB[0].name, 'snare')
})
it('strips .wav extension case-insensitively', () => {
const paths = ['Lib/Prog/KICK.WAV']
const result = groupWavs(paths)
assert.strictEqual(result.Lib.Prog[0].name, 'KICK')
})
it('handles deeply nested paths', () => {
const paths = ['A/B/C/D/file.wav']
const result = groupWavs(paths)
assert.strictEqual(result.A.B.length, 1)
assert.strictEqual(result.A.B[0].name, 'file')
assert.strictEqual(result.A.B[0].fullPath, 'A/B/C/D/file.wav')
})
it('handles flat paths with no directory', () => {
const paths = ['sound.wav']
const result = groupWavs(paths)
assert.strictEqual(Object.keys(result).length, 1)
assert.strictEqual(Object.keys(result['']).length, 1)
})
it('returns empty object for empty input', () => {
const result = groupWavs([])
assert.strictEqual(Object.keys(result).length, 0)
})
})
describe('cleanName', () => {
it('replaces underscores with spaces', () => {
assert.strictEqual(cleanName('hello_world'), 'hello world')
})
it('collapses multiple underscores', () => {
assert.strictEqual(cleanName('AMG_-_Black__II'), 'AMG - Black II')
})
it('trims leading/trailing spaces from underscores', () => {
assert.strictEqual(cleanName('_padded_'), 'padded')
})
it('preserves regular text', () => {
assert.strictEqual(cleanName('Kick Drum'), 'Kick Drum')
})
}) })