1
0

App.svelte 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. <script lang="ts">
  2. import { onMount } from "svelte";
  3. import AboutModal from "./lib/AboutModal.svelte";
  4. import { Service } from "./lib/api";
  5. import { play } from "./lib/audio";
  6. import BankBar from "./lib/BankBar.svelte";
  7. import ConfirmModal from "./lib/ConfirmModal.svelte";
  8. import PadPanel from "./lib/PadPanel.svelte";
  9. import PadPicker from "./lib/PadPicker.svelte";
  10. import { transfer, transferKind } from "./lib/pads";
  11. import SequencePanel from "./lib/SequencePanel.svelte";
  12. import SlotGrid, { type Cell } from "./lib/SlotGrid.svelte";
  13. import { app, exportFolder, exportZip, listen, loadProject, message, newProject, refresh, run, ui, type Tab } from "./lib/store.svelte";
  14. import { BANKS, PER_BANK, bars, gridSlots, tuneText } from "./lib/slots";
  15. onMount(() => {
  16. refresh();
  17. return listen();
  18. });
  19. const st = $derived(app.state);
  20. // The bindings type Go slices as `T[] | null`; Go always sends arrays.
  21. const pads = $derived(st?.pads ?? []);
  22. // Sequences by slot, for the grid and the panel.
  23. const seqBySlot = $derived(new Map((st?.sequences ?? []).map((q) => [q.slot, q])));
  24. function perBank(filled: (slot: number) => boolean): number[] {
  25. return Array.from({ length: BANKS.length }, (_, b) => {
  26. let n = 0;
  27. for (let i = 0; i < PER_BANK; i++) if (filled(b * PER_BANK + i)) n++;
  28. return n;
  29. });
  30. }
  31. const padCounts = $derived(perBank((s) => (pads[s]?.file ?? "") !== ""));
  32. const seqCounts = $derived(perBank((s) => seqBySlot.has(s)));
  33. const padCells: Cell[] = $derived(
  34. st
  35. ? gridSlots(ui.padBank).map((slot) => {
  36. const p = pads[slot];
  37. const badges = [tuneText(p.coarse, p.fine), p.muteGroup ? "M" + p.muteGroup : "", p.hasNotes ? "notes" : ""].filter(Boolean);
  38. return {
  39. slot,
  40. label: p.label,
  41. name: p.name,
  42. meta: p.file ? badges.join(" · ") : "",
  43. aria: `Pad ${p.label}, ${p.file || "empty"}${p.hasNotes ? ", has recorded notes" : ""}`,
  44. };
  45. })
  46. : [],
  47. );
  48. const seqCells: Cell[] = $derived(
  49. st
  50. ? gridSlots(ui.seqBank).map((slot) => {
  51. const q = seqBySlot.get(slot);
  52. const label = pads[slot].label; // pad and sequence slots share labels
  53. return {
  54. slot,
  55. label,
  56. name: q?.name ?? "",
  57. meta: q ? `${q.bpm} · ${bars(q.bars)}` : "",
  58. aria: `Sequence slot ${label}, ${q?.name ?? "empty"}`,
  59. };
  60. })
  61. : [],
  62. );
  63. // Clicking a pad selects it and, if it has a sample, plays it.
  64. async function pickPad(slot: number) {
  65. ui.padSlot = slot;
  66. const p = pads[slot];
  67. if (p?.audio) {
  68. try {
  69. await play(p);
  70. } catch (e) {
  71. app.error = message(e);
  72. }
  73. }
  74. }
  75. // A pad dropped on another: move, swap onto a filled pad, Alt to copy.
  76. function dropPad(from: number, to: number, copy: boolean) {
  77. transfer(transferKind(pads[from], pads[to], copy), from, to);
  78. }
  79. async function rename(e: Event & { currentTarget: HTMLInputElement }) {
  80. const input = e.currentTarget;
  81. const name = input.value;
  82. if (name === st?.name) return;
  83. await run(() => Service.SetName(name));
  84. input.value = app.state?.name ?? name; // shows the old name again if refused
  85. }
  86. function pickTab(t: Tab) {
  87. ui.tab = t;
  88. }
  89. // Arrow keys move between the two tabs, as in the WAI-ARIA tabs pattern.
  90. function tabKey(e: KeyboardEvent) {
  91. if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
  92. ui.tab = ui.tab === "pads" ? "sequences" : "pads";
  93. document.getElementById("tab-" + ui.tab)?.focus();
  94. }
  95. }
  96. </script>
  97. <div class="page">
  98. <header>
  99. <h1>mpckit</h1>
  100. <div class="name">
  101. <label class="eyebrow" for="project-name">Project name</label>
  102. <input id="project-name" type="text" value={st?.name ?? ""} spellcheck="false" autocomplete="off" onchange={rename} />
  103. </div>
  104. <div class="actions">
  105. <button type="button" class="btn" onclick={newProject}>New</button>
  106. <button type="button" class="btn" onclick={loadProject}>Load</button>
  107. <button type="button" class="btn" onclick={exportZip}>Export zip</button>
  108. <button type="button" class="btn primary" onclick={exportFolder}>Export to folder</button>
  109. </div>
  110. <button type="button" class="about-link" onclick={() => (ui.aboutOpen = true)}>About</button>
  111. </header>
  112. {#if app.error}
  113. <div class="error" role="alert">
  114. <span>{app.error}</span>
  115. <button type="button" class="dismiss" aria-label="Dismiss" onclick={() => (app.error = "")}>×</button>
  116. </div>
  117. {/if}
  118. <div class="tabs" role="tablist" aria-label="Views">
  119. <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>
  120. <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>
  121. </div>
  122. {#if st}
  123. {#if ui.tab === "pads"}
  124. <div id="view-pads" class="panels" role="tabpanel" aria-labelledby="tab-pads">
  125. <section class="panel grid-panel" aria-label="Pads">
  126. <div class="grid-head">
  127. <h2 class="eyebrow">Pad bank</h2>
  128. <span class="mono summary">Bank {BANKS[ui.padBank]} · {padCounts[ui.padBank]} of 16 filled</span>
  129. </div>
  130. <BankBar
  131. bank={ui.padBank}
  132. counts={padCounts}
  133. what="pads filled"
  134. onpick={(b) => {
  135. ui.padBank = b;
  136. ui.padSlot = b * PER_BANK;
  137. }}
  138. ondragbank={(b) => (ui.padBank = b)}
  139. />
  140. <SlotGrid cells={padCells} selected={ui.padSlot} onpick={pickPad} dropTarget ondroppad={dropPad} />
  141. <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>
  142. </section>
  143. <PadPanel pad={pads[ui.padSlot]} limits={st.limits} />
  144. </div>
  145. {:else}
  146. <div id="view-sequences" class="panels" role="tabpanel" aria-labelledby="tab-sequences">
  147. <section class="panel grid-panel" aria-label="Sequence slots">
  148. <div class="grid-head">
  149. <h2 class="eyebrow">Sequence bank</h2>
  150. <span class="mono summary">Bank {BANKS[ui.seqBank]} · {seqCounts[ui.seqBank]} of 16 used</span>
  151. </div>
  152. <BankBar
  153. bank={ui.seqBank}
  154. counts={seqCounts}
  155. what="slots used"
  156. onpick={(b) => {
  157. ui.seqBank = b;
  158. ui.seqSlot = b * PER_BANK;
  159. }}
  160. />
  161. <SlotGrid cells={seqCells} selected={ui.seqSlot} onpick={(s) => (ui.seqSlot = s)} />
  162. <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>
  163. </section>
  164. <SequencePanel slot={ui.seqSlot} seq={seqBySlot.get(ui.seqSlot)} limits={st.limits} />
  165. </div>
  166. {/if}
  167. <footer class="mono">
  168. <span>{st.sampleCount} of {st.limits.maxSamples} samples</span>
  169. <span>{st.sequenceCount} of {st.limits.maxSequences} sequences</span>
  170. <span>WAV files only</span>
  171. {#if st.dirty}<span class="dirty">Unsaved changes: export to keep them</span>{/if}
  172. {#if app.notice}<span class="notice" title={app.notice}>{app.notice}</span>{/if}
  173. </footer>
  174. {:else if !app.error}
  175. <p class="hint">Loading…</p>
  176. {/if}
  177. {#if ui.aboutOpen}
  178. <AboutModal onclose={() => (ui.aboutOpen = false)} />
  179. {/if}
  180. {#if ui.picker && st}
  181. <PadPicker picker={ui.picker} pads={pads} onclose={() => (ui.picker = null)} />
  182. {/if}
  183. {#if ui.confirm}
  184. <ConfirmModal c={ui.confirm} />
  185. {/if}
  186. </div>
  187. <style>
  188. /* Exactly the window's height: header, tabs, panels (which take the rest)
  189. and footer. Nothing scrolls at the page level. */
  190. .page {
  191. height: 100%;
  192. max-width: 1440px;
  193. margin: 0 auto;
  194. padding: 12px 20px;
  195. display: flex;
  196. flex-direction: column;
  197. gap: 12px;
  198. }
  199. h1 {
  200. margin: 0;
  201. font-family: var(--display);
  202. font-weight: 700;
  203. font-size: 24px;
  204. letter-spacing: 0.02em;
  205. }
  206. .about-link {
  207. min-height: 40px;
  208. padding: 0 8px;
  209. background: transparent;
  210. color: var(--accent);
  211. border: 0;
  212. font: inherit;
  213. font-weight: 500;
  214. text-decoration: underline;
  215. text-underline-offset: 3px;
  216. cursor: pointer;
  217. }
  218. header {
  219. flex: none;
  220. display: flex;
  221. align-items: center;
  222. gap: 16px;
  223. padding-bottom: 12px;
  224. border-bottom: 1px solid var(--line);
  225. }
  226. .name {
  227. display: flex;
  228. align-items: center;
  229. gap: 10px;
  230. flex: 1 1 auto;
  231. min-width: 0;
  232. max-width: 380px;
  233. }
  234. .name label {
  235. white-space: nowrap;
  236. }
  237. .name input {
  238. height: 40px;
  239. min-width: 120px;
  240. padding: 0 12px;
  241. background: var(--surface);
  242. color: var(--text);
  243. border: 1px solid var(--edge);
  244. border-radius: 6px;
  245. font: inherit;
  246. font-weight: 600;
  247. width: 100%;
  248. }
  249. .actions {
  250. display: flex;
  251. gap: 8px;
  252. margin-left: auto;
  253. }
  254. .tabs {
  255. flex: none;
  256. display: flex;
  257. gap: 4px;
  258. border-bottom: 1px solid var(--line);
  259. }
  260. .tabs button {
  261. height: 40px;
  262. padding: 0 22px;
  263. background: transparent;
  264. color: var(--muted);
  265. border: 0;
  266. border-bottom: 3px solid transparent;
  267. font-family: var(--display);
  268. font-size: 16px;
  269. font-weight: 700;
  270. cursor: pointer;
  271. }
  272. .tabs button[aria-selected="true"] {
  273. color: var(--text);
  274. border-bottom-color: var(--accent);
  275. }
  276. .grid-head {
  277. display: flex;
  278. align-items: baseline;
  279. justify-content: space-between;
  280. gap: 12px;
  281. }
  282. .summary {
  283. font-size: 12px;
  284. color: var(--muted);
  285. }
  286. footer {
  287. flex: none;
  288. display: flex;
  289. gap: 8px 24px;
  290. font-size: 12px;
  291. color: var(--muted);
  292. white-space: nowrap;
  293. min-width: 0;
  294. }
  295. .dirty {
  296. color: var(--accent);
  297. }
  298. .notice {
  299. margin-left: auto;
  300. min-width: 0;
  301. overflow: hidden;
  302. text-overflow: ellipsis;
  303. }
  304. /* Narrow window: drop the visible name label (the input keeps it). */
  305. @media (max-width: 1100px) {
  306. .name label {
  307. position: absolute;
  308. width: 1px;
  309. height: 1px;
  310. overflow: hidden;
  311. clip-path: inset(50%);
  312. }
  313. }
  314. /* Short window: give the grid the hint's lines. */
  315. @media (max-height: 600px) {
  316. .grid-hint {
  317. display: none;
  318. }
  319. }
  320. .error {
  321. flex: none;
  322. display: flex;
  323. align-items: center;
  324. gap: 12px;
  325. padding: 6px 6px 6px 12px;
  326. border: 1px solid #e5484d;
  327. border-radius: 6px;
  328. color: #ffb4b4;
  329. white-space: pre-line; /* missing-file lists come one per line */
  330. }
  331. .error span {
  332. flex: 1;
  333. }
  334. .dismiss {
  335. width: 32px;
  336. height: 32px;
  337. background: transparent;
  338. color: inherit;
  339. border: 0;
  340. font-size: 20px;
  341. cursor: pointer;
  342. }
  343. </style>