Spaces:
Running
Running
| /** | |
| * 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, | |
| }; | |
| } | |