diff --git a/slab/menuband/bin/backfill-takes.mjs b/slab/menuband/bin/backfill-takes.mjs new file mode 100644 index 0000000000..f2fd614477 --- /dev/null +++ b/slab/menuband/bin/backfill-takes.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +// backfill-takes.mjs — upload takes already on disk to /api/menuband-takes. +// +// Finds every `.mp3` that sits next to a `-stems/` folder (the +// pair Menu Band drops on the Desktop) under the folders given, and backs it +// up under your handle. The take's name is its takeId, so running this again +// skips what's already there. +// +// node slab/menuband/bin/backfill-takes.mjs ~/Documents/Shelf # dry run +// node slab/menuband/bin/backfill-takes.mjs ~/Documents/Shelf --upload +// +// Auth is ~/.ac-token (run `ac-login` if it has expired). MENUBAND_TAKES_API +// overrides the endpoint, e.g. http://localhost:8888/api/menuband-takes. + +import { readFile, readdir, stat } from "node:fs/promises"; +import { homedir, hostname } from "node:os"; +import { join, resolve } from "node:path"; + +const API = process.env.MENUBAND_TAKES_API || "https://aesthetic.computer/api/menuband-takes"; +const STEMS = ["tones.wav", "percussion.wav", "voice.wav", "notes.mid", "mix.json"]; +const args = process.argv.slice(2); +const upload = args.includes("--upload"); +const roots = args.filter((a) => !a.startsWith("--")).map((a) => resolve(a)); +const machine = (process.env.MENUBAND_MACHINE || hostname()).replace(/\.local$/, "").toLowerCase(); + +if (roots.length === 0) { + console.error("usage: backfill-takes.mjs ... [--upload]"); + process.exit(1); +} + +// Every mp3 with a sibling `-stems` folder, found up to a few levels down. +async function findTakes(dir, depth = 4, found = new Map()) { + let entries; + try { entries = await readdir(dir, { withFileTypes: true }); } catch { return found; } + const names = new Set(entries.map((e) => e.name)); + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isFile() && entry.name.endsWith(".mp3")) { + const name = entry.name.slice(0, -4); + if (names.has(`${name}-stems`) && !found.has(name)) found.set(name, { name, mp3: path, stems: join(dir, `${name}-stems`) }); + } else if (entry.isDirectory() && depth > 0 && !entry.name.endsWith("-stems")) { + await findTakes(path, depth - 1, found); + } + } + return found; +} + +async function describe(take) { + const files = [{ name: "mix.mp3", path: take.mp3 }]; + for (const stem of STEMS) { + const path = join(take.stems, stem); + try { if ((await stat(path)).isFile()) files.push({ name: stem, path }); } catch {} + } + for (const file of files) file.bytes = (await stat(file.path)).size; + let mix = {}; + try { mix = JSON.parse(await readFile(join(take.stems, "mix.json"), "utf8")); } catch {} + return { + takeId: take.name, + recordedAt: (await stat(take.mp3)).mtime.toISOString(), + machine, + duration: Number(mix.duration) || 0, + bpm: mix.bpm ?? null, + program: mix.program ?? null, + files, + }; +} + +async function call(token, body) { + const res = await fetch(API, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(`${res.status} ${data.error || ""} ${data.missing ? data.missing.join(",") : ""}`.trim()); + return data; +} + +async function send(token, take) { + const begun = await call(token, { + action: "begin", + ...take, + files: take.files.map(({ name, bytes }) => ({ name, bytes })), + }); + if (begun.committed) return `${begun.code} already backed up`; + for (const up of begun.uploads) { + const file = take.files.find((f) => f.name === up.name); + const res = await fetch(up.url, { method: up.method, headers: up.headers, body: await readFile(file.path) }); + if (!res.ok) throw new Error(`PUT ${up.name}: ${res.status} ${(await res.text()).slice(0, 200)}`); + } + await call(token, { action: "commit", code: begun.code }); + return `${begun.code} uploaded`; +} + +const found = new Map(); +for (const root of roots) await findTakes(root, 4, found); +const takes = [...found.values()].sort((a, b) => a.name.localeCompare(b.name)); +console.log(`${takes.length} take${takes.length === 1 ? "" : "s"} on ${machine}${upload ? "" : " (dry run, add --upload)"}`); + +let token; +if (upload) { + const session = JSON.parse(await readFile(join(homedir(), ".ac-token"), "utf8")); + if (session.expires_at && session.expires_at < Date.now()) { + console.error("~/.ac-token has expired; run ac-login first"); + process.exit(1); + } + token = session.access_token; +} + +let failed = 0; +for (const take of takes) { + const info = await describe(take); + const mb = (info.files.reduce((n, f) => n + f.bytes, 0) / 1e6).toFixed(1); + const line = `${info.takeId} ${info.duration.toFixed(1)}s ${info.files.length} files ${mb} MB`; + if (!upload) { console.log(line); continue; } + try { console.log(`${line} → ${await send(token, info)}`); } + catch (error) { failed += 1; console.log(`${line} ✗ ${error.message}`); } +} +if (failed) process.exit(1); diff --git a/spec/menuband-takes-spec.mjs b/spec/menuband-takes-spec.mjs new file mode 100644 index 0000000000..097aee1463 --- /dev/null +++ b/spec/menuband-takes-spec.mjs @@ -0,0 +1,44 @@ +import { parseBegin, objectKey, TAKE_FILES } from "../system/netlify/functions/menuband-takes.mjs"; + +describe("menuband takes", () => { + const base = { + takeId: "take-1", + recordedAt: "2026-09-28T12:00:00Z", + machine: "neo", + duration: 42.5, + bpm: 96, + files: [{ name: "mix.mp3", bytes: 1000 }, { name: "voice.wav", bytes: 2000 }], + }; + + it("keys objects under the owner's sub", () => { + expect(objectKey("auth0|abc", "xyz", "mix.mp3")).toBe("auth0|abc/menuband/xyz/mix.mp3"); + }); + + it("accepts a well-formed take and types its files", () => { + const take = parseBegin(base); + expect(take.takeId).toBe("take-1"); + expect(take.recordedAt.toISOString()).toBe("2026-09-28T12:00:00.000Z"); + expect(take.bpm).toBe(96); + expect(take.program).toBeNull(); + expect(take.files["voice.wav"]).toEqual({ bytes: 2000, contentType: "audio/wav" }); + }); + + it("refuses files outside the take's set", () => { + expect(() => parseBegin({ ...base, files: [{ name: "../evil.sh", bytes: 1 }] })) + .toThrowError(/Unsupported file/); + }); + + it("refuses empty or oversized files", () => { + expect(() => parseBegin({ ...base, files: [{ name: "mix.mp3", bytes: 0 }] })).toThrow(); + expect(() => parseBegin({ ...base, files: [{ name: "mix.mp3", bytes: 200 * 1024 * 1024 }] })).toThrow(); + }); + + it("requires a takeId", () => { + expect(() => parseBegin({ ...base, takeId: "" })).toThrowError(/takeId/); + }); + + it("covers every file Menu Band writes", () => { + for (const name of ["mix.mp3", "tones.wav", "percussion.wav", "voice.wav", "notes.mid", "mix.json"]) + expect(TAKE_FILES[name]).toBeDefined(); + }); +}); diff --git a/system/netlify/functions/menuband-takes.mjs b/system/netlify/functions/menuband-takes.mjs new file mode 100644 index 0000000000..20216ff85a --- /dev/null +++ b/system/netlify/functions/menuband-takes.mjs @@ -0,0 +1,269 @@ +// menuband-takes.mjs — Menu Band recordings, backed up under your handle. +// +// Menu Band (slab/menuband) uploads every take it drops on the Desktop here +// so it can be browsed from the `menuband` piece, the menuband MCP tools and +// Aesel on any machine. Takes are PRIVATE: every call is owner-only, objects +// are written with a private ACL, and reads go through 1-hour presigned GETs. +// +// POST { action: "begin", takeId, recordedAt, machine, duration, bpm?, program?, files: [{ name, bytes }] } +// → { code, committed, uploads: [{ name, url, method, headers }] } +// PUT each file to its url with exactly those headers. +// POST { action: "commit", code } → { code, committed: true } | 409 { missing } +// GET ?limit=50&before= → { handle, count, takes: [Take] } +// GET ?code=X → { take: Take } +// Each Take file carries `url` (inline) and `download` (attachment), +// both presigned GETs that last an hour. +// DELETE ?code=X → { code, deleted: true } +// +// Storage: USER_SPACE_NAME bucket, key `/menuband//`. +// Mongo: `menuband-takes` { user, code, takeId, recordedAt, machine, duration, +// bpm, program, files: { name: { key, bytes, contentType } }, committed, when, nuked? } + +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + HeadObjectCommand, + DeleteObjectsCommand, +} from "@aws-sdk/client-s3"; +import { authorize, hasAdmin, handleFor } from "../../backend/authorization.mjs"; +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; +import { generateUniqueCode } from "../../backend/generate-short-code.mjs"; + +const COLLECTION = "menuband-takes"; +const MAX_BYTES = 96 * 1024 * 1024; +const URL_SECONDS = 3600; +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; +const METHODS = "GET, POST, DELETE, OPTIONS"; + +// The only files a take may carry, and what each one is. +export const TAKE_FILES = { + "mix.mp3": "audio/mpeg", + "mix.wav": "audio/wav", + "tones.wav": "audio/wav", + "percussion.wav": "audio/wav", + "voice.wav": "audio/wav", + "notes.mid": "audio/midi", + "mix.json": "application/json", + "cover.png": "image/png", +}; + +const reply = (status, body) => + respond(status, body, { "Access-Control-Allow-Methods": METHODS }); + +let s3; +function storage() { + const accessKeyId = process.env.ART_KEY || process.env.DO_SPACES_KEY; + const secretAccessKey = process.env.ART_SECRET || process.env.DO_SPACES_SECRET; + if (!accessKeyId || !secretAccessKey) return null; + s3 ||= new S3Client({ + endpoint: "https://" + (process.env.USER_ENDPOINT || "sfo3.digitaloceanspaces.com"), + region: "us-east-1", + credentials: { accessKeyId, secretAccessKey }, + // Otherwise presigned PUTs carry a CRC32 of an empty body and a real + // upload fails its checksum. + requestChecksumCalculation: "WHEN_REQUIRED", + }); + return { s3, bucket: process.env.USER_SPACE_NAME || "user-aesthetic-computer" }; +} + +export const objectKey = (sub, code, name) => `${sub}/menuband/${code}/${name}`; + +// Validate a `begin` body into the record fields it may set. Throws with a +// message fit for a 400. +export function parseBegin(body) { + const takeId = typeof body.takeId === "string" ? body.takeId.trim() : ""; + if (!takeId || takeId.length > 128) throw new Error("takeId is required (≤128 chars)"); + const recordedAt = new Date(body.recordedAt ?? Date.now()); + if (Number.isNaN(recordedAt.getTime())) throw new Error("recordedAt must be a date"); + const duration = Number(body.duration); + if (!Number.isFinite(duration) || duration < 0 || duration > 3600) + throw new Error("duration must be seconds"); + const optional = (value) => (Number.isFinite(Number(value)) && value !== null ? Number(value) : null); + if (!Array.isArray(body.files) || body.files.length === 0) + throw new Error("files must list at least one file"); + const files = {}; + for (const file of body.files) { + const contentType = TAKE_FILES[file?.name]; + if (!contentType) throw new Error(`Unsupported file: ${file?.name}`); + const bytes = Number(file.bytes); + if (!Number.isInteger(bytes) || bytes <= 0 || bytes > MAX_BYTES) + throw new Error(`${file.name} must be 1..${MAX_BYTES} bytes`); + files[file.name] = { bytes, contentType }; + } + return { + takeId, + recordedAt, + machine: String(body.machine || "").slice(0, 64) || null, + duration, + bpm: optional(body.bpm), + program: optional(body.program), + files, + }; +} + +async function presignPut({ s3, bucket }, key, contentType) { + const command = new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: contentType, ACL: "private" }); + // Keep the ACL and type as signed headers (not query params) so the client + // must send exactly these and can't swap them for public-read. + const url = await getSignedUrl(s3, command, { + expiresIn: URL_SECONDS, + signableHeaders: new Set(["content-type", "x-amz-acl"]), + unhoistableHeaders: new Set(["x-amz-acl"]), + }); + return { url, method: "PUT", headers: { "Content-Type": contentType, "x-amz-acl": "private" } }; +} + +async function presignGet({ s3, bucket }, key, name, code, disposition = "inline") { + const command = new GetObjectCommand({ + Bucket: bucket, + Key: key, + ResponseContentDisposition: `${disposition}; filename="menuband-${code}-${name}"`, + }); + return getSignedUrl(s3, command, { expiresIn: URL_SECONDS }); +} + +async function present(store, doc) { + const files = {}; + for (const [name, file] of Object.entries(doc.files || {})) { + files[name] = { + url: await presignGet(store, file.key, name, doc.code), + download: await presignGet(store, file.key, name, doc.code, "attachment"), + bytes: file.bytes, + contentType: file.contentType, + }; + } + return { + code: doc.code, + takeId: doc.takeId, + recordedAt: doc.recordedAt, + machine: doc.machine, + duration: doc.duration, + bpm: doc.bpm, + program: doc.program, + files, + }; +} + +async function begin(store, takes, user, body) { + let fields; + try { fields = parseBegin(body); } + catch (error) { return reply(400, { error: error.message }); } + + const existing = await takes.findOne({ user: user.sub, takeId: fields.takeId, nuked: { $ne: true } }); + if (existing?.committed) return reply(200, { code: existing.code, committed: true, uploads: [] }); + + const code = existing?.code || (await generateUniqueCode(takes, { type: "menuband" })); + const files = {}; + for (const [name, file] of Object.entries(fields.files)) + files[name] = { ...file, key: objectKey(user.sub, code, name) }; + + await takes.updateOne( + { user: user.sub, takeId: fields.takeId }, + { + $set: { ...fields, files, code, committed: false, when: new Date() }, + $unset: { nuked: "" }, + }, + { upsert: true }, + ); + + const uploads = []; + for (const [name, file] of Object.entries(files)) + uploads.push({ name, ...(await presignPut(store, file.key, file.contentType)) }); + return reply(200, { code, committed: false, uploads }); +} + +async function commit({ s3, bucket }, takes, user, body) { + const code = String(body.code || ""); + const doc = await takes.findOne({ user: user.sub, code, nuked: { $ne: true } }); + if (!doc) return reply(404, { error: "No such take" }); + + const missing = []; + const files = { ...doc.files }; + for (const [name, file] of Object.entries(files)) { + try { + const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: file.key })); + files[name] = { ...file, bytes: head.ContentLength ?? file.bytes }; + } catch { + missing.push(name); + } + } + if (missing.length) return reply(409, { error: "Files not uploaded", missing }); + + await takes.updateOne({ _id: doc._id }, { $set: { files, committed: true, when: new Date() } }); + return reply(200, { code, committed: true }); +} + +async function list(store, takes, user, params) { + if (params.code) { + const doc = await takes.findOne({ user: user.sub, code: params.code, committed: true, nuked: { $ne: true } }); + if (!doc) return reply(404, { error: "No such take" }); + return reply(200, { take: await present(store, doc) }); + } + const limit = Math.min(MAX_LIMIT, Math.max(1, parseInt(params.limit, 10) || DEFAULT_LIMIT)); + const query = { user: user.sub, committed: true, nuked: { $ne: true } }; + if (params.before) { + const before = new Date(params.before); + if (!Number.isNaN(before.getTime())) query.recordedAt = { $lt: before }; + } + const docs = await takes.find(query).sort({ recordedAt: -1 }).limit(limit).toArray(); + const presented = []; + for (const doc of docs) presented.push(await present(store, doc)); + return reply(200, { handle: "@" + (await handleFor(user.sub)), count: presented.length, takes: presented }); +} + +async function remove({ s3, bucket }, takes, user, params) { + const code = String(params.code || ""); + const doc = await takes.findOne({ user: user.sub, code, nuked: { $ne: true } }); + if (!doc) return reply(404, { error: "No such take" }); + const objects = Object.values(doc.files || {}).map((file) => ({ Key: file.key })); + if (objects.length) + await s3.send(new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: objects, Quiet: true } })); + await takes.updateOne({ _id: doc._id }, { $set: { nuked: true, when: new Date() } }); + return reply(200, { code, deleted: true }); +} + +export async function handler(event) { + if (event.httpMethod === "OPTIONS") return reply(204, ""); + if (!["GET", "POST", "DELETE"].includes(event.httpMethod)) + return reply(405, { error: "Method not allowed" }); + + const user = await authorize(event.headers); + if (!user?.sub) return reply(401, { error: "Log in to reach your Menu Band takes" }); + + // Admin only (@jeffrey's verified ADMIN_SUB) while the feature is greenlit + // for him alone. + if (!(await hasAdmin(user))) + return reply(403, { error: "Menu Band backup isn't open to your account yet" }); + + const store = storage(); + if (!store) return reply(503, { error: "Take storage is not configured" }); + + let database; + try { + database = await connect(); + const takes = database.db.collection(COLLECTION); + await takes.createIndex({ user: 1, takeId: 1 }, { unique: true }); + await takes.createIndex({ user: 1, recordedAt: -1 }); + await takes.createIndex({ code: 1 }, { unique: true }); + + const params = event.queryStringParameters || {}; + if (event.httpMethod === "GET") return await list(store, takes, user, params); + if (event.httpMethod === "DELETE") return await remove(store, takes, user, params); + + let body; + try { body = JSON.parse(event.body || "{}"); } + catch { return reply(400, { error: "Body must be JSON" }); } + if (body.action === "begin") return await begin(store, takes, user, body); + if (body.action === "commit") return await commit(store, takes, user, body); + return reply(400, { error: "action must be begin or commit" }); + } catch (error) { + console.error("[menuband-takes] error:", error); + return reply(500, { error: error.message || "Internal error" }); + } finally { + await database?.disconnect?.(); + } +} diff --git a/system/public/aesthetic.computer/disks/menuband.mjs b/system/public/aesthetic.computer/disks/menuband.mjs new file mode 100644 index 0000000000..a1b9d4fd1b --- /dev/null +++ b/system/public/aesthetic.computer/disks/menuband.mjs @@ -0,0 +1,177 @@ +// menuband, 2026.9.28 +// Your Menu Band takes, backed up from the Mac app to your handle. +// Tap a take to play its mix; tap `save` to download it. Takes are private, +// so `menuband @handle` only opens your own. Admin-only (@jeffrey) for now. + +const FONT = "MatrixChunky8"; +const ROW = 20; // Two text lines plus a gap. +const TOP = 22; // Clear of the corner label. +const BOTTOM = 12; + +let takes = []; +let status = "loading"; // loading | ready | signed-out | private | error +let message = ""; +let scroll = 0; +let playing = null; // { code, sfx } +let pendingCode = null; // A mix that is still loading. +let saveBoxes = {}; // code → { x, y, w, h, url } registered with bios last paint. + +async function boot({ params, user, handle, authorize, hud }) { + hud.label("menuband"); + const asked = params[0]?.startsWith("@") ? params[0].toLowerCase() : null; + const mine = handle()?.toLowerCase(); + if (!user) return void (status = "signed-out"); + if (asked && asked !== mine) { + status = "private"; + message = `${asked}'s takes are private`; + return; + } + try { + const token = await authorize(); + const res = await fetch("/api/menuband-takes?limit=200", { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json(); + if (res.status === 403) { + status = "private"; + message = data.error; + return; + } + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + takes = data.takes || []; + status = "ready"; + } catch (err) { + status = "error"; + message = err.message; + } +} + +function when(iso) { + const d = new Date(iso); + const month = d.toLocaleString("en-US", { month: "short" }); + const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); + return `${month} ${d.getDate()} ${time}`; +} + +const seconds = (s) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`; +const rowY = (i) => TOP + i * ROW - scroll; +const listHeight = (screen) => screen.height - TOP - BOTTOM; + +function paint({ wipe, ink, screen, text, mask, unmask, send }) { + const { width: w, height: h } = screen; + wipe(14, 12, 22); + + const center = (msg, color) => + ink(...color).write(msg, { center: "xy", x: w / 2, y: h / 2 }, false, w - 16, false, FONT); + if (status !== "ready" || takes.length === 0) { + syncSaveBoxes(send, {}); + if (status === "loading") center("loading takes...", [150, 150, 180]); + else if (status === "signed-out") center("log in to see your takes - type login", [220, 200, 150]); + else if (status === "private") center(message, [220, 200, 150]); + else if (status === "error") center(`error: ${message.slice(0, 60)}`, [255, 110, 110]); + else center("no takes yet - record one in Menu Band", [150, 150, 180]); + return; + } + + const boxes = {}; + mask({ x: 0, y: TOP, width: w, height: listHeight(screen) }); + takes.forEach((take, i) => { + const y = rowY(i); + if (y + ROW < TOP || y > h - BOTTOM) return; + const live = playing?.code === take.code || pendingCode === take.code; + if (live) ink(40, 36, 70).box(0, y, w, ROW - 2); + ink(...(live ? [255, 210, 120] : [230, 220, 255])).write( + `${live ? "■" : "▶"} ${when(take.recordedAt)}`, { x: 6, y: y + 1 }, false, undefined, false, FONT); + const detail = [seconds(take.duration || 0), take.machine, take.bpm && `${Math.round(take.bpm)}bpm`] + .filter(Boolean).join(" · "); + ink(120, 120, 150).write(detail, { x: 6, y: y + 10 }, false, undefined, false, FONT); + + const file = take.files?.["mix.mp3"] || take.files?.["mix.wav"]; + if (file?.download) { + const label = "save"; + const bw = text.width(label, FONT) + 8; + const box = { x: w - bw - 6, y: y + 3, w: bw, h: 12 }; + ink(60, 90, 70).box(box.x, box.y, box.w, box.h); + ink(170, 240, 190).write(label, { x: box.x + 4, y: box.y + 2 }, false, undefined, false, FONT); + // Only fully visible buttons can take a tap. + if (box.y >= TOP && box.y + box.h <= h - BOTTOM) boxes[take.code] = { ...box, url: file.download }; + } + }); + unmask(); + + const count = `${takes.length} take${takes.length === 1 ? "" : "s"}`; + ink(90, 90, 120).write(count, { x: w - text.width(count, FONT) - 6, y: h - BOTTOM + 2 }, false, undefined, false, FONT); + syncSaveBoxes(send, boxes); +} + +// `save` opens its presigned URL from bios' native pointerup, which keeps +// the user gesture iOS needs for a download (see ableton.mjs). +function syncSaveBoxes(send, boxes) { + for (const [code, box] of Object.entries(boxes)) { + const was = saveBoxes[code]; + if (was && was.x === box.x && was.y === box.y && was.w === box.w && was.url === box.url) continue; + const { url, ...rect } = box; + send({ type: "button:hitbox:add", content: { label: `menuband-${code}`, box: rect, url, action: "open-url" } }); + } + for (const code of Object.keys(saveBoxes)) + if (!boxes[code]) send({ type: "button:hitbox:remove", content: `menuband-${code}` }); + saveBoxes = boxes; +} + +function stop() { + playing?.sfx?.kill?.(0.05); + playing = null; + pendingCode = null; +} + +async function play(take, { net, sound }) { + const file = take.files?.["mix.mp3"] || take.files?.["mix.wav"]; + if (!file) return; + const extension = take.files["mix.mp3"] ? "mp3" : "wav"; + const code = take.code; + pendingCode = code; + try { + // Presigned URLs carry dots in their query, so name the type outright. + const sample = await net.preload({ path: file.url, extension }); + if (pendingCode !== code) return; // Another tap got there first. + pendingCode = null; + playing = { + code, + sfx: sound.play(sample, undefined, { kill: () => { if (playing?.code === code) playing = null; } }), + }; + } catch (err) { + if (pendingCode === code) pendingCode = null; + console.warn("menuband: couldn't play", code, err); + } +} + +function act({ event: e, screen, net, sound, jump }) { + if (status !== "ready") { + if (status === "signed-out" && e.is("keyboard:down:enter")) jump("login"); + return; + } + const most = Math.max(0, takes.length * ROW - listHeight(screen)); + if (e.is("draw")) scroll = Math.min(most, Math.max(0, scroll - e.delta.y)); + if (e.is("scroll")) scroll = Math.min(most, Math.max(0, scroll - e.y)); // As chat.mjs. + if (e.is("touch") && e.y >= TOP && e.y < screen.height - BOTTOM) { + if (Object.values(saveBoxes).some((b) => e.x >= b.x && e.x < b.x + b.w && e.y >= b.y && e.y < b.y + b.h)) + return; // bios already opened the download. + const take = takes[Math.floor((e.y - TOP + scroll) / ROW)]; + if (!take) return; + const again = playing?.code === take.code || pendingCode === take.code; + stop(); + if (!again) play(take, { net, sound }); + } + if (e.is("keyboard:down:escape")) jump("prompt"); +} + +function leave({ send }) { + stop(); + syncSaveBoxes(send, {}); +} + +function meta() { + return { title: "Menu Band", desc: "Your Menu Band takes, backed up to your handle." }; +} + +export { boot, paint, act, leave, meta };