/** * Compose view: type a message, see its Morse, pick who transmits (the robot's * antennas or this device's speaker), and send it. */ import { el, clear, morsePattern } from "./dom.js"; import { textToMorse, morseToTokens } from "../lib/morse.js"; import { tokensToOnsets } from "../lib/wire.js"; export function createCompose(ctx) { const root = el("section.view#view-compose"); const input = el("input.compose-input", { type: "text", placeholder: "Type a message… e.g. SOS", value: "SOS", oninput: refresh, }); const morseOut = el("div.morse-out"); const warn = el("div.warn", { hidden: true }); const duration = el("span.muted"); const emitter = segmented( "Who sends it?", [ { value: "robot", label: "🤖 Robot antennas" }, { value: "device", label: "📱 This device" }, ], ctx.state.emitter, (v) => { ctx.state.emitter = v; refresh(); }, ); const sendBtn = el("button.btn.primary.big", { onclick: send }, "Send ▶"); const stopBtn = el("button.btn.ghost", { onclick: stop, hidden: true }, "Stop ◼"); const status = el("div.status muted"); let cancelFn = null; function currentSchedule() { const { morse, skipped } = textToMorse(input.value); const onsets = tokensToOnsets(morseToTokens(morse), ctx.timing()); return { morse, skipped, onsets }; } function refresh() { const { morse, skipped, onsets } = currentSchedule(); clear(morseOut); if (!morse) { morseOut.append(el("span.muted", {}, "Your Morse will appear here.")); } else { morse.split(" / ").forEach((word, wi) => { if (wi > 0) morseOut.append(el("span.wordsep", {}, "/")); word.split(" ").forEach((p) => morseOut.append(morsePattern(p))); }); } if (skipped.length) { warn.hidden = false; warn.textContent = `Can't send in Morse: ${[...new Set(skipped)].join(" ")}`; } else { warn.hidden = true; } const secs = onsets.length ? (onsets[onsets.length - 1] / 1000 + 0.3) : 0; duration.textContent = onsets.length ? `${onsets.length} taps · ~${secs.toFixed(1)}s` : ""; emitter.setValue(ctx.state.emitter); } async function send() { const { onsets } = currentSchedule(); if (!onsets.length) return; setSending(true); const controller = new AbortController(); const highlight = (i) => flashTap(i, onsets.length); try { if (ctx.state.emitter === "robot") { if (!ctx.reachy) { ctx.toast("No robot connected."); return; } cancelFn = () => controller.abort(); await ctx.tapper().tap(onsets, { onTap: highlight, signal: controller.signal }); } else { await new Promise((resolve) => { const cancel = ctx.synth().play(onsets, { onBlip: highlight, onDone: resolve, clickMs: ctx.timing().clickMs, }); cancelFn = () => { cancel(); resolve(); }; }); } } catch (e) { ctx.toast(`Send failed: ${e?.message || e}`); } finally { setSending(false); cancelFn = null; } } function stop() { cancelFn?.(); ctx.synth().stop(); ctx.tapper()?.stop?.(); setSending(false); } function setSending(on) { sendBtn.hidden = on; stopBtn.hidden = !on; input.disabled = on; } function flashTap(i, total) { status.textContent = `Transmitting… ${i + 1}/${total}`; if (i + 1 >= total) setTimeout(() => { status.textContent = "Sent ✓"; }, 200); } root.append( el("h2", {}, "Compose"), input, el("div.row.between", {}, [el("span.muted", {}, "Morse preview"), duration]), morseOut, warn, emitter.node, el("div.row.send-row", {}, [sendBtn, stopBtn]), status, ); refresh(); return { node: root, onShow: refresh }; } function segmented(label, options, value, onChange) { const btns = new Map(); const group = el("div.segmented"); options.forEach((o) => { const b = el("button.seg", { onclick: () => { setValue(o.value); onChange(o.value); }, }, o.label); btns.set(o.value, b); group.append(b); }); function setValue(v) { value = v; btns.forEach((b, k) => b.classList.toggle("active", k === v)); } setValue(value); return { node: el("div.field", {}, [el("label.field-label", {}, label), group]), setValue, }; }