wai/wai-ui becomes wai/wai-app, the library `wai_app`, with no binary and no `cli` feature: `wai`, built with its feature `app`, is its command line and its MCP server. Its served files are named wai-app-* (the WebAssembly package wai_app), the recordings wai-app-*.json. - export.rs gives the files of a starter project for a program and the facts its project file holds (title, summary, `wai dev`'s mode for the app's room, a watch room's file, a show's audience); `wai create --from app.oui` writes them, the project file and the licence. The export no longer writes app.json, room.toml, relay.toml, package.json, check.mjs or serve.mjs: wai.json names the room, `wai dev` runs it and `wai app check` checks the project. Its README and AGENTS.md say so, and the README its licence. - runtime.rs reads a Create runtime by its listing (create/wai-app-runtime.json), checking each file's SHA-256. - bridge.rs keeps the bridge on 127.0.0.1 a coding agent streams a program into the page through, with a stream helper; the stdio server, its tools and the binary's test go (`wai mcp --toolset app` serves them). - The page's Export hands over app.oui, the program in its canonical form, and the command that makes it a project; the ZIP writer and the page's export in WebAssembly go. The page names `wai mcp --toolset app` for the coding agent. - joule-ui/docs/create.md §4 and §5: the export as `wai create --from app.oui`, and the agent's tools app_guide, app_check, app_stream and app_export. Co-Authored-By: Claude Opus 5.5 <[email protected]>
461 lines
22 KiB
JavaScript
461 lines
22 KiB
JavaScript
// The WAI widgets of wai-app's registry, as components of the streaming renderer.
|
|
//
|
|
// A room widget (Room, WatchTogether, Show) runs the room client of joule-moq-web in a frame
|
|
// of this page's own origin (room-frame.html): the page that makes its own key, asks the
|
|
// room service for a grant, proves the key to the relay, checks every audio and video
|
|
// object against its signed receipt before it is shown, keeps a watch room in sync and
|
|
// follows a show's polls. The widgets inside the room drive that client through its own
|
|
// interface (`roomTest`), from this page: every action a person takes (join, watch, leave,
|
|
// send a chat, open a poll, vote, play, pause) goes through the page's session and is
|
|
// sealed into its log (session.js).
|
|
|
|
const h = (tag, attrs = {}, ...kids) => {
|
|
const el = document.createElement(tag);
|
|
for (const [k, v] of Object.entries(attrs)) {
|
|
if (v == null || v === false) continue;
|
|
if (k === "class") el.className = v;
|
|
else if (k === "text") el.textContent = v;
|
|
else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
|
|
else el.setAttribute(k, v === true ? "" : String(v));
|
|
}
|
|
for (const c of kids) if (c != null) el.append(c);
|
|
return el;
|
|
};
|
|
const short = (p) => (p ? `${p.slice(0, 8)}…` : "");
|
|
const text = (props, k, d = "") => (typeof props[k] === "string" ? props[k] : d);
|
|
|
|
// ---------------------------------------------------------------------------- the room
|
|
|
|
// The page's one room: the widget that holds it sets it, the widgets inside it read it.
|
|
export class RoomSlot {
|
|
constructor() { this.current = null; this.subs = new Set(); }
|
|
set(handle) { this.current = handle; for (const f of this.subs) f(handle); }
|
|
clear(handle) { if (this.current === handle) this.set(null); }
|
|
on(f) { this.subs.add(f); if (this.current) f(this.current); return () => this.subs.delete(f); }
|
|
}
|
|
|
|
// The room client in its frame, and what the widgets ask of it.
|
|
export class RoomHandle {
|
|
constructor(kind, frame) {
|
|
this.kind = kind;
|
|
this.frame = frame;
|
|
this.subs = new Set();
|
|
this.last = null;
|
|
this.timer = setInterval(() => this.tick(), 600);
|
|
}
|
|
get t() {
|
|
try { return this.frame.contentWindow && this.frame.contentWindow.roomTest; } catch { return null; }
|
|
}
|
|
// Ready to enter: the client has read its room service and waits for a role.
|
|
async ready(ms = 30000) {
|
|
const end = Date.now() + ms;
|
|
for (;;) {
|
|
const t = this.t;
|
|
if (t && t.enter && t.state && t.state.phase === "idle") return t;
|
|
if (t && t.state && t.state.phase === "refused") throw new Error("the room client refused its endpoints");
|
|
if (Date.now() > end) throw new Error("the room client did not load");
|
|
await new Promise((r) => setTimeout(r, 150));
|
|
}
|
|
}
|
|
async enter(role) {
|
|
const t = await this.ready();
|
|
const phase = await t.enter(role);
|
|
this.tick();
|
|
return { phase, principal: this.me() };
|
|
}
|
|
async leave() { const t = this.t; if (t && t.leave) await t.leave(); this.tick(); return "left"; }
|
|
state() { const t = this.t; return (t && t.state) || null; }
|
|
phase() { const s = this.state(); return s ? s.phase : "loading"; }
|
|
me() { return (this.last && this.last.me && this.last.me.principal) || null; }
|
|
role() { const t = this.t; return t && t.role; }
|
|
snapshot() { try { return this.t && this.t.snapshot ? this.t.snapshot() : null; } catch { return null; } }
|
|
chats() {
|
|
const s = this.state();
|
|
return ((s && s.timeline) || []).filter((e) => e.kind === "chat").map((e) => ({ t: e.t, who: e.who, text: e.payload && e.payload.text, digest: e.digest }));
|
|
}
|
|
async sendChat(text) { const v = await this.t.sendChat(text); this.tick(); return { t: v && v.t, digest: v && v.digest }; }
|
|
co() { try { return this.t && this.t.coState ? this.t.coState() : null; } catch { return null; } }
|
|
async openPoll(question, options, seconds) {
|
|
const r = await this.t.coRequest("/co/window", { rule: "plurality", options, k: 1, seconds, question });
|
|
if (r.status !== 200) throw new Error(`the room's clock answered ${r.status}: ${JSON.stringify(r.body)}`);
|
|
return { status: r.status };
|
|
}
|
|
async vote(window, option) { const v = await this.t.coInput({ window, type: "vote", option }); return { window, option, t: v && v.t }; }
|
|
async watch(name, arg) { const at = await this.t.watchControl(name, arg); return { name, arg, at }; }
|
|
// The watch room's player position, in microseconds.
|
|
position() {
|
|
try { const m = this.frame.contentDocument.getElementById("cowatch-media"); return Math.round((m.currentTime || 0) * 1e6); } catch { return 0; }
|
|
}
|
|
// Objects received and checked against their receipts, over every participant and track.
|
|
checked() {
|
|
const s = this.state();
|
|
const out = { received: 0, verified: 0, refused: 0 };
|
|
for (const sub of Object.values((s && s.subs) || {})) {
|
|
for (const m of ["audio", "video", "screen"]) {
|
|
const x = sub[m];
|
|
if (!x) continue;
|
|
out.received += x.received || 0;
|
|
out.verified += x.verified || 0;
|
|
out.refused += x.refused || 0;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
present() {
|
|
const s = this.state();
|
|
return ((s && s.room && s.room.present) || []).map((p) => p.principal);
|
|
}
|
|
setView(view) {
|
|
try { this.frame.contentDocument.body.dataset.view = view; } catch { /* not loaded yet: the query has it */ }
|
|
}
|
|
tick() {
|
|
this.last = this.snapshot();
|
|
for (const f of this.subs) { try { f(this); } catch { /* a view's own fault */ } }
|
|
}
|
|
on(f) { this.subs.add(f); f(this); return () => this.subs.delete(f); }
|
|
destroy() {
|
|
clearInterval(this.timer);
|
|
this.subs.clear();
|
|
try { if (this.t && this.t.leave && ["publishing", "watching"].includes(this.phase())) this.t.leave(); } catch { /* gone */ }
|
|
}
|
|
}
|
|
|
|
const KIND = {
|
|
Room: { kind: "live", label: "Live room", join: "Join with camera and microphone", watch: "Watch", view: "none" },
|
|
WatchTogether: { kind: "watch", label: "Watching together", join: null, watch: "Watch together", view: "watch" },
|
|
Show: { kind: "show", label: "Show", join: "Join as a host", watch: "Watch", view: "none" },
|
|
};
|
|
|
|
const PHASE = {
|
|
loading: "The room client is loading…",
|
|
idle: "Not in the room yet.",
|
|
entering: "Entering…",
|
|
publishing: "In the room, on camera and microphone.",
|
|
watching: "Watching.",
|
|
full: "The room is full: you can watch.",
|
|
left: "You left the room.",
|
|
refused: "This page cannot reach the room service.",
|
|
};
|
|
|
|
function roomWidget(name) {
|
|
const k = KIND[name];
|
|
return (ctx, props) => {
|
|
const title = h("h2", { class: "wai-room-title", text: text(props, "title", k.label) });
|
|
const status = h("span", { class: "wai-room-status", role: "status", "aria-live": "polite", text: PHASE.loading });
|
|
const join = k.join ? h("button", { type: "button", class: "wai-btn wai-primary", "data-act": "join", text: k.join }) : null;
|
|
const watch = h("button", { type: "button", class: "wai-btn", "data-act": "watch", text: k.watch });
|
|
const leave = h("button", { type: "button", class: "wai-btn", "data-act": "leave", hidden: true, text: "Leave" });
|
|
const frame = h("iframe", { class: "wai-frame", title: `${k.label}: the room`, allow: "camera; microphone; autoplay", loading: "eager" });
|
|
const stage = h("div", { class: "wai-room-stage", "data-view": k.view }, frame);
|
|
const slot = h("div", { class: "wai-room-children" });
|
|
const el = h("section", { class: "wai-room", "data-kind": k.kind },
|
|
h("header", { class: "wai-room-head" }, title, h("span", { class: "wai-kind", text: k.label })),
|
|
h("div", { class: "wai-room-controls" }, join, watch, leave, status),
|
|
stage, slot);
|
|
const u = new URL(ctx.config.frame, location.href);
|
|
u.searchParams.set("service", (ctx.config.services && ctx.config.services[k.kind]) || ctx.config.service);
|
|
u.searchParams.set("view", k.view);
|
|
frame.src = u.toString();
|
|
const handle = new RoomHandle(k.kind, frame);
|
|
handle.stage = stage;
|
|
handle.views = new Set();
|
|
handle.showVideo = (on) => {
|
|
if (on) handle.views.add("video"); else handle.views.delete("video");
|
|
const view = k.view === "watch" ? "watch" : handle.views.size ? "stage" : "none";
|
|
stage.dataset.view = view;
|
|
handle.setView(view);
|
|
};
|
|
// The frame's height follows its content.
|
|
const fit = () => {
|
|
try {
|
|
const d = frame.contentDocument;
|
|
if (d && d.body) frame.style.height = `${Math.max(0, d.body.scrollHeight)}px`;
|
|
} catch { /* not ours yet */ }
|
|
};
|
|
frame.addEventListener("load", () => {
|
|
handle.setView(stage.dataset.view);
|
|
try { new ResizeObserver(fit).observe(frame.contentDocument.body); } catch { /* no observer */ }
|
|
fit();
|
|
});
|
|
const busy = (b) => { for (const x of [join, watch, leave]) if (x) x.disabled = b; };
|
|
const act = (action, role) => async () => {
|
|
busy(true);
|
|
try {
|
|
if (role) await ctx.session.fire(action, { room: k.kind, role }, () => handle.enter(role));
|
|
else await ctx.session.fire(action, { room: k.kind }, () => handle.leave());
|
|
} catch (e) {
|
|
status.textContent = `${PHASE[handle.phase()] || ""} ${e.message || e}`;
|
|
}
|
|
busy(false);
|
|
render();
|
|
};
|
|
if (join) join.addEventListener("click", act("room.join", "publisher"));
|
|
watch.addEventListener("click", act("room.watch", "viewer"));
|
|
leave.addEventListener("click", act("room.leave", null));
|
|
const render = () => {
|
|
const p = handle.phase();
|
|
const inRoom = p === "publishing" || p === "watching";
|
|
status.textContent = PHASE[p] || p;
|
|
if (join) join.hidden = inRoom || p === "left";
|
|
watch.hidden = inRoom || p === "left";
|
|
leave.hidden = !inRoom;
|
|
el.dataset.phase = p;
|
|
};
|
|
handle.on(render);
|
|
ctx.room.set(handle);
|
|
return {
|
|
el,
|
|
slot,
|
|
handle,
|
|
update(next) { title.textContent = text(next, "title", k.label); },
|
|
destroy() { ctx.room.clear(handle); handle.destroy(); },
|
|
};
|
|
};
|
|
}
|
|
|
|
// The widgets inside a room find it through the page's room slot.
|
|
function inRoom(ctx, draw) {
|
|
let handle = null;
|
|
let off = null;
|
|
const offSlot = ctx.room.on((hd) => {
|
|
if (off) off();
|
|
handle = hd;
|
|
off = hd ? hd.on(() => draw(hd)) : null;
|
|
if (!hd) draw(null);
|
|
});
|
|
return { get handle() { return handle; }, stop() { if (off) off(); offSlot(); } };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------- the widgets
|
|
|
|
function App(ctx, props) {
|
|
const title = h("h1", { class: "wai-app-title", text: text(props, "title") });
|
|
const slot = h("div", { class: "wai-app-children" });
|
|
const el = h("div", { class: "wai-app-shell" }, title, slot);
|
|
return { el, slot, update(p) { title.textContent = text(p, "title"); } };
|
|
}
|
|
|
|
function Text(ctx, props) {
|
|
const el = h("p", { class: "wai-text", text: text(props, "text") });
|
|
return { el, update(p) { el.textContent = text(p, "text"); } };
|
|
}
|
|
|
|
function Player(ctx, props) {
|
|
const el = h("div", { class: "wai-player", text: text(props, "title", "The room's video") });
|
|
const r = inRoom(ctx, () => {});
|
|
const on = ctx.room.on((hd) => { if (hd && hd.showVideo) hd.showVideo(true); });
|
|
return {
|
|
el,
|
|
update(p) { el.textContent = text(p, "title", "The room's video"); },
|
|
destroy() { on(); const hd = r.handle; if (hd && hd.showVideo) hd.showVideo(false); r.stop(); },
|
|
};
|
|
}
|
|
|
|
function Chat(ctx, props) {
|
|
const head = h("h3", { class: "wai-h", text: text(props, "title", "Chat") });
|
|
const list = h("ul", { class: "wai-chat-list", "aria-live": "polite" });
|
|
const input = h("input", { class: "wai-input", maxlength: "500", autocomplete: "off", "aria-label": "Your message" });
|
|
const send = h("button", { type: "submit", class: "wai-btn", text: "Send" });
|
|
const note = h("p", { class: "wai-note" });
|
|
const form = h("form", { class: "wai-chat-form" }, input, send);
|
|
const el = h("section", { class: "wai-chat" }, head, list, form, note);
|
|
let shown = 0;
|
|
const r = inRoom(ctx, (hd) => {
|
|
const chats = hd ? hd.chats() : [];
|
|
if (chats.length !== shown) {
|
|
list.replaceChildren(...chats.map((c) => h("li", { "data-digest": c.digest || "" }, h("b", { text: `${c.who}: ` }), c.text || "")));
|
|
shown = chats.length;
|
|
list.scrollTop = list.scrollHeight;
|
|
}
|
|
const p = hd ? hd.phase() : "loading";
|
|
const can = p === "publishing";
|
|
input.disabled = send.disabled = !can;
|
|
note.textContent = can ? "Each message is signed by your key and checked by everyone before it shows." : p === "watching" ? "Viewers read the chat; those who join write it." : "Join the room to write.";
|
|
});
|
|
form.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const t = input.value.trim();
|
|
const hd = r.handle;
|
|
if (!t || !hd) return;
|
|
input.value = "";
|
|
try { await ctx.session.fire("chat.send", { text: t }, () => hd.sendChat(t)); } catch (err) { note.textContent = String(err.message || err); }
|
|
});
|
|
return { el, update(p) { head.textContent = text(p, "title", "Chat"); }, destroy() { r.stop(); } };
|
|
}
|
|
|
|
function Poll(ctx, props) {
|
|
let p = props;
|
|
const q = h("h3", { class: "wai-h wai-poll-q" });
|
|
const opts = h("div", { class: "wai-poll-options" });
|
|
const open = h("button", { type: "button", class: "wai-btn wai-primary", hidden: true, text: "Open the poll" });
|
|
const result = h("p", { class: "wai-poll-result", role: "status", "aria-live": "polite" });
|
|
const note = h("p", { class: "wai-note" });
|
|
const el = h("section", { class: "wai-poll" }, q, opts, open, result, note);
|
|
const options = () => (Array.isArray(p.options) ? p.options : []).map(String);
|
|
const question = () => text(p, "question");
|
|
const seconds = () => (typeof p.seconds === "number" ? p.seconds : 30);
|
|
let voted = null;
|
|
const draw = (hd) => {
|
|
q.textContent = question();
|
|
const co = hd ? hd.co() : null;
|
|
const labels = options();
|
|
const windows = co ? co.windows.filter((w) => w.question === question()) : [];
|
|
const w = windows[windows.length - 1] || null;
|
|
const now = co ? co.now : 0;
|
|
const isOpen = !!w && w.close > now;
|
|
const audience = co && co.audience && co.audience.limit > 0;
|
|
open.hidden = !(co && co.lead) || isOpen;
|
|
open.disabled = false;
|
|
if (opts.dataset.labels !== JSON.stringify(labels) || opts.dataset.open !== String(isOpen && audience)) {
|
|
opts.dataset.labels = JSON.stringify(labels);
|
|
opts.dataset.open = String(isOpen && audience);
|
|
opts.replaceChildren(...labels.map((label, i) => {
|
|
const b = h("button", { type: "button", class: "wai-btn wai-option", "data-option": String(i), text: label, disabled: !(isOpen && audience) });
|
|
b.addEventListener("click", async () => {
|
|
const ww = w;
|
|
if (!ww || !hd) return;
|
|
for (const x of opts.querySelectorAll("button")) x.disabled = true;
|
|
try { await ctx.session.fire("poll.vote", { question: question(), option: i, label }, () => hd.vote(ww.digest, i)); voted = i; } catch (e) { note.textContent = String(e.message || e); }
|
|
});
|
|
return b;
|
|
}));
|
|
}
|
|
if (w && w.selection) {
|
|
const pick = w.selection.picks[0];
|
|
const credit = w.selection.credits[0];
|
|
result.textContent = pick && pick.option != null ? `The room chose “${labels[pick.option] || `option ${pick.option + 1}`}”, with ${credit ? credit.count : 0} vote${credit && credit.count === 1 ? "" : "s"}; the room's clock signed the result.` : "The poll closed with no vote.";
|
|
el.dataset.state = "closed";
|
|
} else if (isOpen) {
|
|
result.textContent = `Open: closes in ${Math.max(0, Math.round((w.close - now) / 1e6))} s · ${w.inputs} vote${w.inputs === 1 ? "" : "s"} so far${voted != null ? ` · you chose “${labels[voted]}”` : ""}`;
|
|
el.dataset.state = "open";
|
|
} else if (w) {
|
|
result.textContent = "Closed: the room's clock is counting.";
|
|
el.dataset.state = "counting";
|
|
} else {
|
|
result.textContent = co ? (co.lead ? "Open it when you are ready." : "The host opens the poll.") : "Join or watch the show to take part.";
|
|
el.dataset.state = "waiting";
|
|
}
|
|
note.textContent = audience ? "Your vote is signed by your key; the room's clock selects and signs the result." : co && co.lead ? "You lead this show: you open its polls." : "";
|
|
};
|
|
const r = inRoom(ctx, draw);
|
|
open.addEventListener("click", async () => {
|
|
const hd = r.handle;
|
|
if (!hd) return;
|
|
open.disabled = true;
|
|
try { await ctx.session.fire("poll.open", { question: question(), options: options() }, () => hd.openPoll(question(), options().length, seconds())); } catch (e) { note.textContent = String(e.message || e); }
|
|
});
|
|
draw(null);
|
|
return { el, update(next) { p = next; draw(r.handle); }, destroy() { r.stop(); } };
|
|
}
|
|
|
|
function Participants(ctx, props) {
|
|
const head = h("h3", { class: "wai-h", text: text(props, "title", "In the room") });
|
|
const list = h("ul", { class: "wai-people" });
|
|
const el = h("section", { class: "wai-participants" }, head, list);
|
|
const r = inRoom(ctx, (hd) => {
|
|
const ps = hd ? hd.present() : [];
|
|
const me = hd ? hd.me() : null;
|
|
const json = JSON.stringify([ps, me]);
|
|
if (list.dataset.v === json) return;
|
|
list.dataset.v = json;
|
|
list.replaceChildren(...(ps.length ? ps.map((p) => h("li", { "data-principal": p, text: p === me ? `you (${short(p)})` : short(p) })) : [h("li", { class: "wai-note", text: "No one yet." })]));
|
|
});
|
|
return { el, update(p) { head.textContent = text(p, "title", "In the room"); }, destroy() { r.stop(); } };
|
|
}
|
|
|
|
function Receipts(ctx, props) {
|
|
const head = h("h3", { class: "wai-h", text: text(props, "title", "Receipts") });
|
|
const verdict = h("p", { class: "wai-verdict", role: "status" });
|
|
const table = h("table", { class: "wai-log" }, h("thead", {}, h("tr", {}, ...["#", "Action", "Verdict", "Energy", "Link"].map((t) => h("th", { text: t })))));
|
|
const body = h("tbody");
|
|
table.append(body);
|
|
const room = h("p", { class: "wai-note" });
|
|
const el = h("section", { class: "wai-receipts" }, head, verdict, h("div", { class: "wai-scroll" }, table), room);
|
|
const draw = () => {
|
|
const log = ctx.session.log();
|
|
const v = ctx.session.verify();
|
|
verdict.textContent = v.verified != null
|
|
? `${v.verified} action${v.verified === 1 ? "" : "s"} sealed by this page's key (${short(ctx.session.actor.replace("did:key:", ""))}), the chain verified link by link.`
|
|
: `The chain breaks at link ${v.broken_at}.`;
|
|
verdict.dataset.ok = String(v.verified != null);
|
|
body.replaceChildren(...log.slice(-12).map((e) => h("tr", { "data-seq": String(e.seq) },
|
|
h("td", { text: String(e.seq) }), h("td", { text: e.action }), h("td", { text: e.verdict }),
|
|
h("td", { text: `${e.energy.estimated_uj} µJ (${e.energy.provenance})` }), h("td", { class: "wai-mono", text: e.id.slice(3, 15) }))));
|
|
};
|
|
const off = ctx.session.on(draw);
|
|
const r = inRoom(ctx, (hd) => {
|
|
if (!hd) { room.textContent = ""; return; }
|
|
const c = hd.checked();
|
|
room.textContent = `The room: ${c.verified} of ${c.received} objects received checked against their receipts${c.refused ? `, ${c.refused} refused` : ""}.`;
|
|
});
|
|
draw();
|
|
return { el, update(p) { head.textContent = text(p, "title", "Receipts"); }, destroy() { off(); r.stop(); } };
|
|
}
|
|
|
|
function EnergyMeter(ctx, props) {
|
|
const head = h("h3", { class: "wai-h", text: text(props, "title", "Energy") });
|
|
const total = h("p", { class: "wai-energy-total" });
|
|
const list = h("ul", { class: "wai-energy" });
|
|
const note = h("p", { class: "wai-note", text: "A page has no energy meter: each figure is an estimate (the action's time on this page at 20 W), labelled Estimator, never a reading." });
|
|
const el = h("section", { class: "wai-energymeter" }, head, total, list, note);
|
|
const draw = () => {
|
|
const log = ctx.session.log();
|
|
const by = new Map();
|
|
for (const e of log) if (e.verdict === "allow") by.set(e.action, (by.get(e.action) || 0) + e.energy.estimated_uj);
|
|
const sum = ctx.session.totalUj();
|
|
total.textContent = `${(sum / 1e6).toFixed(3)} J over ${log.length} action${log.length === 1 ? "" : "s"} (Estimator)`;
|
|
list.replaceChildren(...[...by.entries()].map(([a, uj]) => h("li", { text: `${a}: ${(uj / 1e3).toFixed(1)} mJ` })));
|
|
};
|
|
const off = ctx.session.on(draw);
|
|
draw();
|
|
return { el, update(p) { head.textContent = text(p, "title", "Energy"); }, destroy() { off(); } };
|
|
}
|
|
|
|
function WatchTogetherControls(ctx) {
|
|
// The shared player's controls, each a sealed action: they apply to everyone in the room.
|
|
const mk = (label, act) => h("button", { type: "button", class: "wai-btn", "data-act": act, text: label });
|
|
const play = mk("Play", "play"), pause = mk("Pause", "pause"), back = mk("Back 10 s", "back"), fwd = mk("Ahead 10 s", "ahead");
|
|
const el = h("div", { class: "wai-watch-controls", hidden: true }, play, pause, back, fwd);
|
|
const r = inRoom(ctx, (hd) => { el.hidden = !hd || hd.phase() !== "watching"; });
|
|
const go = (name, arg) => async () => {
|
|
const hd = r.handle;
|
|
if (!hd) return;
|
|
const a = typeof arg === "function" ? arg(hd) : arg;
|
|
try { await ctx.session.fire(`watch.${name}`, { arg: a }, () => hd.watch(name, a)); } catch { /* shown by the room */ }
|
|
};
|
|
play.addEventListener("click", go("play", 0));
|
|
pause.addEventListener("click", go("pause", 0));
|
|
back.addEventListener("click", go("seek", (hd) => Math.max(0, hd.position() - 10e6)));
|
|
fwd.addEventListener("click", go("seek", (hd) => hd.position() + 10e6));
|
|
return { el, stop: () => r.stop() };
|
|
}
|
|
|
|
function WatchTogether(ctx, props, node) {
|
|
const c = roomWidget("WatchTogether")(ctx, props, node);
|
|
const controls = WatchTogetherControls(ctx);
|
|
c.el.insertBefore(controls.el, c.slot);
|
|
const destroy = c.destroy;
|
|
c.destroy = () => { controls.stop(); destroy(); };
|
|
return c;
|
|
}
|
|
|
|
export const components = {
|
|
App,
|
|
Text,
|
|
Room: roomWidget("Room"),
|
|
WatchTogether,
|
|
Show: roomWidget("Show"),
|
|
Player,
|
|
Chat,
|
|
Poll,
|
|
Participants,
|
|
Receipts,
|
|
EnergyMeter,
|
|
};
|
|
|
|
// The context the components share: the page's session, its one room, and where the room
|
|
// service (`service`, or one for each kind of room, `services`) and the room client's frame
|
|
// are, each on this page's origin.
|
|
export function context({ session, service, services = {}, frame }) {
|
|
return { session, room: new RoomSlot(), config: { service, services, frame } };
|
|
}
|