| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362 |
- <script lang="ts">
- import { onMount } from "svelte";
- import AboutModal from "./lib/AboutModal.svelte";
- import { Service } from "./lib/api";
- import { play } from "./lib/audio";
- import BankBar from "./lib/BankBar.svelte";
- import ConfirmModal from "./lib/ConfirmModal.svelte";
- import PadPanel from "./lib/PadPanel.svelte";
- import PadPicker from "./lib/PadPicker.svelte";
- import { transfer, transferKind } from "./lib/pads";
- import SequencePanel from "./lib/SequencePanel.svelte";
- import SlotGrid, { type Cell } from "./lib/SlotGrid.svelte";
- import { app, exportFolder, exportZip, listen, loadProject, message, newProject, refresh, run, ui, type Tab } from "./lib/store.svelte";
- import { BANKS, PER_BANK, bars, gridSlots, tuneText } from "./lib/slots";
- onMount(() => {
- refresh();
- return listen();
- });
- const st = $derived(app.state);
- // The bindings type Go slices as `T[] | null`; Go always sends arrays.
- const pads = $derived(st?.pads ?? []);
- // Sequences by slot, for the grid and the panel.
- const seqBySlot = $derived(new Map((st?.sequences ?? []).map((q) => [q.slot, q])));
- function perBank(filled: (slot: number) => boolean): number[] {
- return Array.from({ length: BANKS.length }, (_, b) => {
- let n = 0;
- for (let i = 0; i < PER_BANK; i++) if (filled(b * PER_BANK + i)) n++;
- return n;
- });
- }
- const padCounts = $derived(perBank((s) => (pads[s]?.file ?? "") !== ""));
- const seqCounts = $derived(perBank((s) => seqBySlot.has(s)));
- const padCells: Cell[] = $derived(
- st
- ? gridSlots(ui.padBank).map((slot) => {
- const p = pads[slot];
- const badges = [tuneText(p.coarse, p.fine), p.muteGroup ? "M" + p.muteGroup : "", p.hasNotes ? "notes" : ""].filter(Boolean);
- return {
- slot,
- label: p.label,
- name: p.name,
- meta: p.file ? badges.join(" · ") : "",
- aria: `Pad ${p.label}, ${p.file || "empty"}${p.hasNotes ? ", has recorded notes" : ""}`,
- };
- })
- : [],
- );
- const seqCells: Cell[] = $derived(
- st
- ? gridSlots(ui.seqBank).map((slot) => {
- const q = seqBySlot.get(slot);
- const label = pads[slot].label; // pad and sequence slots share labels
- return {
- slot,
- label,
- name: q?.name ?? "",
- meta: q ? `${q.bpm} · ${bars(q.bars)}` : "",
- aria: `Sequence slot ${label}, ${q?.name ?? "empty"}`,
- };
- })
- : [],
- );
- // Clicking a pad selects it and, if it has a sample, plays it.
- async function pickPad(slot: number) {
- ui.padSlot = slot;
- const p = pads[slot];
- if (p?.audio) {
- try {
- await play(p);
- } catch (e) {
- app.error = message(e);
- }
- }
- }
- // A pad dropped on another: move, swap onto a filled pad, Alt to copy.
- function dropPad(from: number, to: number, copy: boolean) {
- transfer(transferKind(pads[from], pads[to], copy), from, to);
- }
- async function rename(e: Event & { currentTarget: HTMLInputElement }) {
- const input = e.currentTarget;
- const name = input.value;
- if (name === st?.name) return;
- await run(() => Service.SetName(name));
- input.value = app.state?.name ?? name; // shows the old name again if refused
- }
- function pickTab(t: Tab) {
- ui.tab = t;
- }
- // Arrow keys move between the two tabs, as in the WAI-ARIA tabs pattern.
- function tabKey(e: KeyboardEvent) {
- if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
- ui.tab = ui.tab === "pads" ? "sequences" : "pads";
- document.getElementById("tab-" + ui.tab)?.focus();
- }
- }
- </script>
- <div class="page">
- <header>
- <h1>mpckit</h1>
- <div class="name">
- <label class="eyebrow" for="project-name">Project name</label>
- <input id="project-name" type="text" value={st?.name ?? ""} spellcheck="false" autocomplete="off" onchange={rename} />
- </div>
- <div class="actions">
- <button type="button" class="btn" onclick={newProject}>New</button>
- <button type="button" class="btn" onclick={loadProject}>Load</button>
- <button type="button" class="btn" onclick={exportZip}>Export zip</button>
- <button type="button" class="btn primary" onclick={exportFolder}>Export to folder</button>
- </div>
- <button type="button" class="about-link" onclick={() => (ui.aboutOpen = true)}>About</button>
- </header>
- {#if app.error}
- <div class="error" role="alert">
- <span>{app.error}</span>
- <button type="button" class="dismiss" aria-label="Dismiss" onclick={() => (app.error = "")}>×</button>
- </div>
- {/if}
- <div class="tabs" role="tablist" aria-label="Views">
- <button id="tab-pads" type="button" role="tab" aria-selected={ui.tab === "pads"} aria-controls="view-pads" tabindex={ui.tab === "pads" ? 0 : -1} onclick={() => pickTab("pads")} onkeydown={tabKey}>Pads</button>
- <button id="tab-sequences" type="button" role="tab" aria-selected={ui.tab === "sequences"} aria-controls="view-sequences" tabindex={ui.tab === "sequences" ? 0 : -1} onclick={() => pickTab("sequences")} onkeydown={tabKey}>Sequences</button>
- </div>
- {#if st}
- {#if ui.tab === "pads"}
- <div id="view-pads" class="panels" role="tabpanel" aria-labelledby="tab-pads">
- <section class="panel grid-panel" aria-label="Pads">
- <div class="grid-head">
- <h2 class="eyebrow">Pad bank</h2>
- <span class="mono summary">Bank {BANKS[ui.padBank]} · {padCounts[ui.padBank]} of 16 filled</span>
- </div>
- <BankBar
- bank={ui.padBank}
- counts={padCounts}
- what="pads filled"
- onpick={(b) => {
- ui.padBank = b;
- ui.padSlot = b * PER_BANK;
- }}
- ondragbank={(b) => (ui.padBank = b)}
- />
- <SlotGrid cells={padCells} selected={ui.padSlot} onpick={pickPad} dropTarget ondroppad={dropPad} />
- <p class="hint grid-hint">Click a pad to select and hear it. Drag a pad to move it, drag onto a filled pad to swap, hold Alt while dragging to copy. Drag over a bank letter to reach another bank. Drop a WAV file on any pad to assign it.</p>
- </section>
- <PadPanel pad={pads[ui.padSlot]} limits={st.limits} />
- </div>
- {:else}
- <div id="view-sequences" class="panels" role="tabpanel" aria-labelledby="tab-sequences">
- <section class="panel grid-panel" aria-label="Sequence slots">
- <div class="grid-head">
- <h2 class="eyebrow">Sequence bank</h2>
- <span class="mono summary">Bank {BANKS[ui.seqBank]} · {seqCounts[ui.seqBank]} of 16 used</span>
- </div>
- <BankBar
- bank={ui.seqBank}
- counts={seqCounts}
- what="slots used"
- onpick={(b) => {
- ui.seqBank = b;
- ui.seqSlot = b * PER_BANK;
- }}
- />
- <SlotGrid cells={seqCells} selected={ui.seqSlot} onpick={(s) => (ui.seqSlot = s)} />
- <p class="hint grid-hint">Each pad is one sequence slot, {st.limits.maxSequences} in total. Click an empty slot to add a sequence there.</p>
- </section>
- <SequencePanel slot={ui.seqSlot} seq={seqBySlot.get(ui.seqSlot)} limits={st.limits} />
- </div>
- {/if}
- <footer class="mono">
- <span>{st.sampleCount} of {st.limits.maxSamples} samples</span>
- <span>{st.sequenceCount} of {st.limits.maxSequences} sequences</span>
- <span>WAV files only</span>
- {#if st.dirty}<span class="dirty">Unsaved changes: export to keep them</span>{/if}
- {#if app.notice}<span class="notice" title={app.notice}>{app.notice}</span>{/if}
- </footer>
- {:else if !app.error}
- <p class="hint">Loading…</p>
- {/if}
- {#if ui.aboutOpen}
- <AboutModal onclose={() => (ui.aboutOpen = false)} />
- {/if}
- {#if ui.picker && st}
- <PadPicker picker={ui.picker} pads={pads} onclose={() => (ui.picker = null)} />
- {/if}
- {#if ui.confirm}
- <ConfirmModal c={ui.confirm} />
- {/if}
- </div>
- <style>
- /* Exactly the window's height: header, tabs, panels (which take the rest)
- and footer. Nothing scrolls at the page level. */
- .page {
- height: 100%;
- max-width: 1440px;
- margin: 0 auto;
- padding: 12px 20px;
- display: flex;
- flex-direction: column;
- gap: 12px;
- }
- h1 {
- margin: 0;
- font-family: var(--display);
- font-weight: 700;
- font-size: 24px;
- letter-spacing: 0.02em;
- }
- .about-link {
- min-height: 40px;
- padding: 0 8px;
- background: transparent;
- color: var(--accent);
- border: 0;
- font: inherit;
- font-weight: 500;
- text-decoration: underline;
- text-underline-offset: 3px;
- cursor: pointer;
- }
- header {
- flex: none;
- display: flex;
- align-items: center;
- gap: 16px;
- padding-bottom: 12px;
- border-bottom: 1px solid var(--line);
- }
- .name {
- display: flex;
- align-items: center;
- gap: 10px;
- flex: 1 1 auto;
- min-width: 0;
- max-width: 380px;
- }
- .name label {
- white-space: nowrap;
- }
- .name input {
- height: 40px;
- min-width: 120px;
- padding: 0 12px;
- background: var(--surface);
- color: var(--text);
- border: 1px solid var(--edge);
- border-radius: 6px;
- font: inherit;
- font-weight: 600;
- width: 100%;
- }
- .actions {
- display: flex;
- gap: 8px;
- margin-left: auto;
- }
- .tabs {
- flex: none;
- display: flex;
- gap: 4px;
- border-bottom: 1px solid var(--line);
- }
- .tabs button {
- height: 40px;
- padding: 0 22px;
- background: transparent;
- color: var(--muted);
- border: 0;
- border-bottom: 3px solid transparent;
- font-family: var(--display);
- font-size: 16px;
- font-weight: 700;
- cursor: pointer;
- }
- .tabs button[aria-selected="true"] {
- color: var(--text);
- border-bottom-color: var(--accent);
- }
- .grid-head {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- gap: 12px;
- }
- .summary {
- font-size: 12px;
- color: var(--muted);
- }
- footer {
- flex: none;
- display: flex;
- gap: 8px 24px;
- font-size: 12px;
- color: var(--muted);
- white-space: nowrap;
- min-width: 0;
- }
- .dirty {
- color: var(--accent);
- }
- .notice {
- margin-left: auto;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- /* Narrow window: drop the visible name label (the input keeps it). */
- @media (max-width: 1100px) {
- .name label {
- position: absolute;
- width: 1px;
- height: 1px;
- overflow: hidden;
- clip-path: inset(50%);
- }
- }
- /* Short window: give the grid the hint's lines. */
- @media (max-height: 600px) {
- .grid-hint {
- display: none;
- }
- }
- .error {
- flex: none;
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 6px 6px 6px 12px;
- border: 1px solid #e5484d;
- border-radius: 6px;
- color: #ffb4b4;
- white-space: pre-line; /* missing-file lists come one per line */
- }
- .error span {
- flex: 1;
- }
- .dismiss {
- width: 32px;
- height: 32px;
- background: transparent;
- color: inherit;
- border: 0;
- font-size: 20px;
- cursor: pointer;
- }
- </style>
|