morse-code / views /compose.js
RemiFabre
ui: compact, denser layout so message + transcript fit together
4f5e7fd
Raw
History Blame Contribute Delete
4.89 kB
/**
* 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,
};
}