wenay-react2 1.0.47 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +14 -14
  2. package/doc/EXAMPLE_USAGE.md +969 -969
  3. package/doc/PROJECT_FUNCTIONALITY.md +403 -403
  4. package/doc/PROJECT_RULES.md +27 -27
  5. package/doc/WENAY_REACT2_RENAMES.md +170 -170
  6. package/doc/changes/v1.0.35.md +18 -18
  7. package/doc/changes/v1.0.38.md +37 -37
  8. package/doc/changes/v1.0.39.md +23 -23
  9. package/doc/changes/v1.0.40.md +14 -14
  10. package/doc/changes/v1.0.41.md +35 -35
  11. package/doc/changes/v1.0.42.md +26 -26
  12. package/doc/changes/v1.0.43.md +10 -10
  13. package/doc/changes/v1.0.44.md +14 -14
  14. package/doc/changes/v1.0.45.md +8 -8
  15. package/doc/changes/v1.0.46.md +11 -11
  16. package/doc/changes/v1.0.47.md +9 -9
  17. package/doc/changes/v1.0.48.md +8 -0
  18. package/doc/changes/v1.0.49.md +7 -0
  19. package/doc/examples/README.md +5 -4
  20. package/doc/examples/conference-client.html +34 -0
  21. package/doc/examples/conference-client.ts +212 -0
  22. package/doc/examples/conference-server.mjs +150 -0
  23. package/doc/examples/peer-call-media.tsx +7 -7
  24. package/doc/examples/stand.tsx +5 -5
  25. package/doc/native.md +37 -37
  26. package/doc/progress/README.md +13 -13
  27. package/doc/progress/architecture-fix-queue.md +74 -74
  28. package/doc/progress/column-state-present-gate.md +28 -28
  29. package/doc/progress/common2-adoption-1.0.73.md +28 -28
  30. package/doc/progress/common2-adoption-1.0.74.md +24 -24
  31. package/doc/progress/hook-controller-opportunities.md +363 -363
  32. package/doc/progress/hook-extraction-audit.md +195 -195
  33. package/doc/progress/public-surface-normalization.md +351 -351
  34. package/doc/progress/qa-stand-walkthrough-2026-07-12.md +62 -0
  35. package/doc/progress/stand-as-examples-audit.md +20 -20
  36. package/doc/progress/style-system-normalization.md +121 -121
  37. package/doc/target/README.md +32 -32
  38. package/doc/target/my.md +134 -124
  39. package/doc/wenay-react2-rare.md +807 -807
  40. package/doc/wenay-react2.md +543 -541
  41. package/lib/common/demo/fakeRtcLoopback.d.ts +17 -0
  42. package/lib/common/demo/fakeRtcLoopback.js +108 -0
  43. package/lib/common/demo/peerConference.d.ts +449 -0
  44. package/lib/common/demo/peerConference.js +372 -0
  45. package/lib/common/demo/peerMedia.js +6 -1
  46. package/lib/common/src/hooks/index.d.ts +1 -0
  47. package/lib/common/src/hooks/index.js +1 -0
  48. package/lib/common/src/hooks/useRoute.d.ts +28 -0
  49. package/lib/common/src/hooks/useRoute.js +30 -0
  50. package/lib/common/src/logs/logsController.js +2 -2
  51. package/lib/common/testUseReact/qa.js +4 -3
  52. package/lib/style/menuRight.css +19 -19
  53. package/lib/style/style.css +23 -23
  54. package/lib/style/tokens.css +184 -184
  55. package/package.json +2 -1
  56. package/doc/wenay-react2-1.0.8.tgz +0 -0
@@ -0,0 +1,372 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Shipped conference demonstration: a 3-way host-star room where media travels over
4
+ * BOTH technologies at once — the shared media relay (Peer.createMediaRelay, the grid)
5
+ * and a policy-routed WebRTC direct line (Replay.createRouteCoordinator, the focus tile).
6
+ * The QA board imports this exact component; consumers can import it from
7
+ * `wenay-react2/demo/peer-conference`.
8
+ *
9
+ * Load-bearing invariant: the focus link's relay hop and its WebRTC direct route both
10
+ * serve the SAME owner-sequenced routed line (replayListen per seat). Route hand-offs
11
+ * resume from `lastDelivered` and silently drop lower seq, so serving two different
12
+ * journals (e.g. a relay.watchOf line — per-watcher re-sequenced) would freeze frames
13
+ * after a switch. Never "simplify" the relay hop to watchOf.
14
+ *
15
+ * Frames are JSON (SVG data-URL snapshots): the replay channel wire is text and the
16
+ * webrtc connector declares binary:false. Real-camera binary frames through
17
+ * Media.attachVideoCanvas stay covered by QA cards 43-45.
18
+ */
19
+ import { useEffect, useMemo, useRef, useState } from "react";
20
+ import { listen as createListen, Peer, Replay } from "wenay-common2";
21
+ import { usePeerCalls } from "../src/hooks/usePeerCall";
22
+ import { useRouteState } from "../src/hooks/useRoute";
23
+ import { createFakeRtcNet } from "./fakeRtcLoopback";
24
+ const seatHue = (account) => {
25
+ let hash = 0;
26
+ for (const ch of account)
27
+ hash = (hash * 31 + ch.charCodeAt(0)) % 360;
28
+ return hash;
29
+ };
30
+ const defaultFrame = (account, n) => {
31
+ const hue = seatHue(account);
32
+ const cx = 16 + (n * 9) % 128;
33
+ const cy = 24 + (n * 5) % 44;
34
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="90">` +
35
+ `<rect width="160" height="90" fill="hsl(${hue},65%,42%)"/>` +
36
+ `<circle cx="${cx}" cy="${cy}" r="7" fill="#fff" opacity="0.9"/>` +
37
+ `<text x="8" y="82" font-size="12" font-family="monospace" fill="#fff">${account} #${n}</text></svg>`;
38
+ return { n, at: Date.now(), image: "data:image/svg+xml," + encodeURIComponent(svg) };
39
+ };
40
+ function makeChannelPair() {
41
+ let closed = false;
42
+ function side(other) {
43
+ let onMsg = null;
44
+ let onCls = null;
45
+ const me = {
46
+ send: (data) => { if (!closed)
47
+ setTimeout(function deliverHop() { if (!closed)
48
+ other().deliver(data); }, 0); },
49
+ onMessage: (cb) => { onMsg = cb; },
50
+ onClose: (cb) => { onCls = cb; },
51
+ close: () => {
52
+ if (closed)
53
+ return;
54
+ closed = true;
55
+ setTimeout(function closeHop() { me.fireClose(); other().fireClose(); }, 0);
56
+ },
57
+ deliver: (data) => onMsg?.(data),
58
+ fireClose: () => onCls?.(),
59
+ };
60
+ return me;
61
+ }
62
+ let a, b;
63
+ a = side(() => b);
64
+ b = side(() => a);
65
+ return [a, b];
66
+ }
67
+ /** Hand-built relay-route connector: a fresh serveReplayChannel hop over an in-proc text
68
+ * channel per open(). Fresh-per-open matters: the coordinator closes the relay connector
69
+ * on a successful promote and calls connect(ref, "relay") anew on every demote. */
70
+ function makeRelayHopConnector(line) {
71
+ let state = "idle";
72
+ let stopServe = null;
73
+ let serverEnd = null;
74
+ return {
75
+ info: { label: "relay", kind: "relay", ordered: true, reliable: true },
76
+ open() {
77
+ state = "opening";
78
+ const [server, client] = makeChannelPair();
79
+ serverEnd = server;
80
+ stopServe = Replay.serveReplayChannel(Replay.exposeReplay(line), server);
81
+ state = "open";
82
+ return Replay.channelReplayRemote(client);
83
+ },
84
+ close() {
85
+ stopServe?.();
86
+ stopServe = null;
87
+ serverEnd?.close();
88
+ serverEnd = null;
89
+ state = "closed";
90
+ },
91
+ state: () => state,
92
+ };
93
+ }
94
+ /* ---------- the headless conference world ---------- */
95
+ export function createConferenceWorld(options = {}) {
96
+ const accounts = options.accounts ?? ["conf-a", "conf-b", "conf-c"];
97
+ const hostAccount = accounts[0];
98
+ const room = options.room ?? "qa-conference";
99
+ const frameOf = options.frame ?? defaultFrame;
100
+ // client policy refs (the coordinator hooks read them live) + the server-side gate
101
+ const policy = { forceRelay: false, allowEndpoint: true };
102
+ const host = Peer.createPeerHost({
103
+ authorize: env => env.type === "offer" ? policy.allowEndpoint : true,
104
+ });
105
+ // exactly ONE connection per account: the signal hub delivers to the LAST registered
106
+ // port, and the call manager, webrtc connector and acceptor deliberately share it
107
+ const connections = new Map(accounts.map(account => [account, host.connection(account)]));
108
+ const managers = new Map(accounts.map(account => [account,
109
+ Peer.createCallManager({ port: Peer.callPortOf(connections.get(account).fragment), self: account })]));
110
+ // roster = host + every member whose host-star call is active; it is the single
111
+ // authority for BOTH media planes (relay ACL and direct accept/canDirect)
112
+ const roster = new Set([hostAccount]);
113
+ const [emitChanged, changed] = createListen();
114
+ const hostCalls = new Map();
115
+ function ring(member) {
116
+ const existing = hostCalls.get(member);
117
+ if (existing && existing.state() !== "ended")
118
+ return existing;
119
+ const handle = managers.get(hostAccount).call(member, { room });
120
+ hostCalls.set(member, handle);
121
+ handle.changed.on(function onStarCallState(state) {
122
+ if (state === "active")
123
+ roster.add(member);
124
+ emitChanged();
125
+ });
126
+ void handle.ended.then(function onStarCallEnd() {
127
+ roster.delete(member);
128
+ if (hostCalls.get(member) === handle)
129
+ hostCalls.delete(member);
130
+ emitChanged();
131
+ });
132
+ emitChanged();
133
+ return handle;
134
+ }
135
+ const relay = Peer.createMediaRelay({
136
+ lines: { cam: "video" },
137
+ videoHistory: 8,
138
+ canWatch: (watcher, owner) => watcher !== owner && roster.has(watcher) && roster.has(owner),
139
+ });
140
+ // one owner-sequenced routed line per seat: the seq authority BOTH routes serve
141
+ const routed = new Map(accounts.map(account => {
142
+ const [emit, line] = Replay.replayListen({ history: 8, current: "last" });
143
+ return [account, { emit, line }];
144
+ }));
145
+ const frameNumbers = new Map(accounts.map(account => [account, 0]));
146
+ const publishers = new Map(accounts.map(account => [account, relay.publishOf(account)]));
147
+ /** Seats stream only while in the room (the host always is): leaving freezes every
148
+ * consumer of that seat on any route, joining resumes them. */
149
+ function tick(only) {
150
+ for (const account of accounts) {
151
+ if (only && account !== only)
152
+ continue;
153
+ if (!roster.has(account))
154
+ continue;
155
+ const n = (frameNumbers.get(account) ?? 0) + 1;
156
+ frameNumbers.set(account, n);
157
+ const frame = frameOf(account, n);
158
+ publishers.get(account)("cam", frame, frame.at);
159
+ routed.get(account).emit(frame, frame.at);
160
+ }
161
+ }
162
+ const fps = options.fps ?? 8;
163
+ const painter = fps > 0 ? setInterval(function paintConference() { tick(); }, Math.max(16, Math.round(1000 / fps))) : null;
164
+ // WebRTC runtime: injected factory or the browser's real RTCPeerConnection
165
+ const livePcs = new Set();
166
+ const baseRtc = options.rtc ?? (() => new globalThis.RTCPeerConnection());
167
+ const rtc = () => {
168
+ const pc = baseRtc();
169
+ livePcs.add(pc);
170
+ return pc;
171
+ };
172
+ // every seat serves ITS OWN routed line to direct viewers; membership gates accept
173
+ const acceptorStops = accounts.map(owner => Replay.acceptWebRtcDirect({
174
+ port: connections.get(owner).fragment.signal,
175
+ rtc,
176
+ self: owner,
177
+ serve: env => env.pair === "media:" + owner + ">" + env.from
178
+ ? Replay.exposeReplay(routed.get(owner).line)
179
+ : null,
180
+ accept: env => roster.has(env.from) && roster.has(owner),
181
+ }));
182
+ // focus coordinator for the first seat: directional signaling pair "media:OWNER>VIEWER"
183
+ // (the coordinator's own ref.key is symmetric — never reuse it for signaling)
184
+ const viewer = hostAccount;
185
+ const ownerOf = (ref) => ref.a === viewer ? ref.b : ref.a;
186
+ const coordinator = Replay.createRouteCoordinator({
187
+ policy: {
188
+ mustRelay: () => policy.forceRelay,
189
+ canDirect: ctx => roster.has(ownerOf(ctx)),
190
+ },
191
+ connect: (ref, kind) => {
192
+ const owner = ownerOf(ref);
193
+ if (kind === "relay")
194
+ return makeRelayHopConnector(routed.get(owner).line);
195
+ return Replay.createWebRtcConnector({
196
+ port: connections.get(viewer).fragment.signal,
197
+ rtc,
198
+ self: viewer,
199
+ peer: owner,
200
+ pair: "media:" + owner + ">" + viewer,
201
+ openTimeoutMs: 8000,
202
+ });
203
+ },
204
+ });
205
+ const focusLinks = new Map();
206
+ function focus(owner) {
207
+ let link = focusLinks.get(owner);
208
+ if (!link) {
209
+ link = coordinator.pair(viewer, owner);
210
+ focusLinks.set(owner, link);
211
+ }
212
+ return link;
213
+ }
214
+ /** Server-side revoke of the viewer's live direct session; the connector fails and
215
+ * the coordinator auto-falls back to the relay hop without losing frames. */
216
+ function revokeDirect(owner) {
217
+ host.revoke("media:" + owner + ">" + viewer, [viewer], "server revoke");
218
+ }
219
+ /** Transport-death drill: the direct channel dies mid-flight (not a policy event). */
220
+ function killDirect() {
221
+ if (options.killTransport) {
222
+ options.killTransport();
223
+ return;
224
+ }
225
+ for (const pc of livePcs)
226
+ pc.close();
227
+ livePcs.clear();
228
+ }
229
+ let closed = false;
230
+ function close() {
231
+ if (closed)
232
+ return;
233
+ closed = true;
234
+ if (painter)
235
+ clearInterval(painter);
236
+ coordinator.close();
237
+ for (const stop of acceptorStops)
238
+ stop();
239
+ for (const handle of hostCalls.values())
240
+ if (handle.state() !== "ended")
241
+ handle.hangup();
242
+ for (const manager of managers.values())
243
+ manager.close();
244
+ relay.close();
245
+ for (const entry of routed.values())
246
+ entry.line.close();
247
+ for (const connection of connections.values())
248
+ connection.close();
249
+ host.close();
250
+ }
251
+ return {
252
+ accounts,
253
+ hostAccount,
254
+ viewer,
255
+ room,
256
+ managers,
257
+ relay,
258
+ coordinator,
259
+ focus,
260
+ ring,
261
+ hostCalls,
262
+ roster: () => [...roster].sort(),
263
+ inRoster: (account) => roster.has(account),
264
+ changed,
265
+ lineOf: (account) => routed.get(account).line,
266
+ frameNumber: (account) => frameNumbers.get(account) ?? 0,
267
+ tick,
268
+ setForceRelay(on) { policy.forceRelay = on; emitChanged(); },
269
+ setAllowEndpoint(on) { policy.allowEndpoint = on; emitChanged(); },
270
+ getPolicy: () => ({ ...policy }),
271
+ revokeDirect,
272
+ killDirect,
273
+ close,
274
+ };
275
+ }
276
+ /* ---------- React demo: 3 seats, relay grid, direct focus ---------- */
277
+ const tileStyle = { width: 160, height: 90, background: "#111", borderRadius: 6, objectFit: "cover" };
278
+ const panelStyle = { border: "1px solid #d0d7de", borderRadius: 8, padding: 8, display: "grid", gap: 6, alignContent: "start" };
279
+ const SeatPanel = (p) => {
280
+ const manager = p.world.managers.get(p.account);
281
+ const calls = usePeerCalls(manager);
282
+ const isHost = p.account === p.world.hostAccount;
283
+ const members = p.world.accounts.filter(account => account !== p.world.hostAccount);
284
+ const incoming = calls.rings.find(call => call.direction === "in");
285
+ const live = calls.calls.filter(call => call.state() === "active");
286
+ return _jsxs("div", { style: panelStyle, children: [_jsxs("b", { children: [p.account, isHost ? " (host)" : "", " ", p.world.inRoster(p.account) ? "· in room" : ""] }), isHost && members.map(member => {
287
+ const handle = p.world.hostCalls.get(member);
288
+ const state = handle?.state();
289
+ return _jsxs("div", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [(!handle || state === "ended") && _jsxs("button", { onClick: () => p.world.ring(member), children: ["ring ", member] }), state === "ringing" && _jsxs("span", { children: ["ringing ", member, "\u2026"] }), state === "active" && _jsxs("button", { onClick: () => handle.hangup(), children: ["hang up ", member] })] }, member);
290
+ }), !isHost && incoming && _jsxs("div", { style: { display: "flex", gap: 6 }, children: [_jsx("button", { onClick: () => incoming.accept(), children: "accept" }), _jsx("button", { onClick: () => incoming.decline(), children: "decline" })] }), !isHost && live.map(call => _jsxs("button", { onClick: () => call.hangup(), children: ["hang up (", call.peer, ")"] }, call.id)), _jsxs("span", { style: { fontSize: 11, color: "#57606a" }, children: ["calls: ", calls.calls.map(call => `${call.peer}:${call.state()}`).join(" ") || "none"] })] });
291
+ };
292
+ const GridTile = (p) => {
293
+ const imgRef = useRef(null);
294
+ const counter = useRef({ n: 0, at: 0 });
295
+ const [snapshot, setSnapshot] = useState({ n: 0, ageMs: 0 });
296
+ useEffect(() => {
297
+ // watchOf hides owners the ACL denies (the property reads as undefined), so the
298
+ // tile attaches lazily and retries whenever room membership changes
299
+ let off = null;
300
+ const tryAttach = () => {
301
+ if (off)
302
+ return;
303
+ const watch = p.world.relay.watchOf(p.viewer);
304
+ const line = watch[p.owner];
305
+ if (!line)
306
+ return;
307
+ off = line.cam.on((frame) => {
308
+ counter.current = { n: frame.n, at: frame.at };
309
+ if (imgRef.current)
310
+ imgRef.current.src = frame.image;
311
+ });
312
+ };
313
+ tryAttach();
314
+ const offChanged = p.world.changed.on(tryAttach);
315
+ const timer = window.setInterval(() => setSnapshot({
316
+ n: counter.current.n,
317
+ ageMs: counter.current.at ? Date.now() - counter.current.at : 0,
318
+ }), 500);
319
+ return () => { off?.(); offChanged(); window.clearInterval(timer); };
320
+ }, [p.world, p.viewer, p.owner]);
321
+ return _jsxs("figure", { style: { margin: 0, display: "grid", gap: 2, justifyItems: "start" }, children: [_jsx("img", { ref: imgRef, alt: `${p.viewer} watches ${p.owner}`, style: tileStyle }), _jsxs("figcaption", { style: { fontSize: 11, color: "#57606a" }, children: [p.viewer, " \u2190 ", p.owner, " \u00B7 #", snapshot.n, " \u00B7 ", Math.round(snapshot.ageMs / 100) / 10, "s"] })] });
322
+ };
323
+ const FocusPanel = (p) => {
324
+ const members = p.world.accounts.filter(account => account !== p.world.viewer);
325
+ const [owner, setOwner] = useState(members[0]);
326
+ const link = useMemo(() => p.world.focus(owner), [p.world, owner]);
327
+ const route = useRouteState(p.world.coordinator, link);
328
+ const imgRef = useRef(null);
329
+ const counter = useRef({ n: 0, seq: 0 });
330
+ const [snapshot, setSnapshot] = useState({ n: 0, seq: 0 });
331
+ const [lastOp, setLastOp] = useState("");
332
+ const [forceRelay, setForceRelay] = useState(false);
333
+ const [allowEndpoint, setAllowEndpoint] = useState(true);
334
+ useEffect(() => {
335
+ let off = null;
336
+ let seq = () => 0;
337
+ try {
338
+ const sub = link.subscribe(frame => {
339
+ counter.current = { n: frame.n, seq: seq() };
340
+ if (imgRef.current)
341
+ imgRef.current.src = frame.image;
342
+ });
343
+ seq = sub.seq;
344
+ off = sub;
345
+ }
346
+ catch (error) {
347
+ setLastOp("subscribe failed: " + String(error));
348
+ }
349
+ const timer = window.setInterval(() => setSnapshot({ ...counter.current }), 500);
350
+ return () => { off?.(); window.clearInterval(timer); };
351
+ }, [link]);
352
+ const run = (label, op) => void op
353
+ .then(result => setLastOp(`${label}: ${result.ok ? "ok" : "denied"} → ${result.state}${result.reason ? ` (${String(result.reason)})` : ""}`))
354
+ .catch(error => setLastOp(`${label}: ${String(error)}`));
355
+ const chipColor = route.state === "direct" ? "#1a7f37" : route.state.startsWith("direct") ? "#9a6700" : route.state === "fallback" ? "#cf222e" : "#0969da";
356
+ return _jsxs("div", { style: { ...panelStyle, minWidth: 320 }, children: [_jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }, children: [_jsxs("b", { children: ["focus: ", p.world.viewer, " \u2190"] }), _jsx("select", { value: owner, onChange: event => setOwner(event.target.value), children: members.map(member => _jsx("option", { value: member, children: member }, member)) }), _jsx("span", { style: { padding: "2px 8px", borderRadius: 10, fontSize: 12, color: "#fff", background: chipColor }, children: route.state })] }), _jsx("img", { ref: imgRef, alt: `focus on ${owner}`, style: { ...tileStyle, width: 240, height: 135 } }), _jsxs("span", { style: { fontSize: 11, color: "#57606a" }, children: ["frame #", snapshot.n, " \u00B7 seq ", snapshot.seq, route.metrics?.relay ? ` · relay:${route.metrics.relay.state}` : "", route.metrics?.direct ? ` · direct:${route.metrics.direct.state}` : ""] }), _jsxs("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [_jsx("button", { onClick: () => run("promote", link.promoteDirect({ timeoutMs: 8000 })), children: "go direct" }), _jsx("button", { onClick: () => run("relay", link.reinterposeRelay("manual")), children: "back to relay" }), _jsx("button", { onClick: () => p.world.revokeDirect(owner), children: "server revoke" }), _jsx("button", { onClick: () => p.world.killDirect(), children: "kill direct transport" })] }), _jsxs("label", { style: { fontSize: 12 }, children: [_jsx("input", { type: "checkbox", checked: forceRelay, onChange: event => { setForceRelay(event.target.checked); p.world.setForceRelay(event.target.checked); } }), " policy: force relay"] }), _jsxs("label", { style: { fontSize: 12 }, children: [_jsx("input", { type: "checkbox", checked: !allowEndpoint, onChange: event => { setAllowEndpoint(!event.target.checked); p.world.setAllowEndpoint(!event.target.checked); } }), " server: refuse endpoint exposure"] }), lastOp && _jsx("span", { style: { fontSize: 12 }, children: lastOp }), route.log.length > 0 && _jsx("span", { style: { fontSize: 11, color: "#57606a" }, children: route.log.map(entry => `${entry.from}→${entry.to}${entry.reason ? ` (${String(entry.reason)})` : ""}`).join(" | ") })] });
357
+ };
358
+ export const ConferenceCallDemo = () => {
359
+ const hasRealRtc = typeof globalThis.RTCPeerConnection === "function";
360
+ const [simulateRtc, setSimulateRtc] = useState(!hasRealRtc);
361
+ const setup = useMemo(() => {
362
+ const net = simulateRtc ? createFakeRtcNet() : null;
363
+ const world = createConferenceWorld(net ? { rtc: net.pc, killTransport: net.killLiveChannels } : {});
364
+ return { net, world };
365
+ }, [simulateRtc]);
366
+ useEffect(() => () => setup.world.close(), [setup]);
367
+ const [, setVersion] = useState(0);
368
+ useEffect(() => setup.world.changed.on(() => setVersion(value => value + 1)), [setup]);
369
+ const world = setup.world;
370
+ const pairs = world.accounts.flatMap(viewer => world.accounts.filter(owner => owner !== viewer).map(owner => ({ viewer, owner })));
371
+ return _jsxs("div", { style: { display: "grid", gap: 10 }, children: [_jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }, children: [_jsxs("b", { children: ["room: ", world.roster().join(", ")] }), _jsxs("label", { style: { fontSize: 12 }, children: [_jsx("input", { type: "checkbox", checked: simulateRtc, onChange: event => setSimulateRtc(event.target.checked) }), " simulate RTC (loopback runtime", hasRealRtc ? "" : "; real RTCPeerConnection unavailable here", ")"] })] }), _jsx("div", { style: { display: "grid", gridTemplateColumns: "repeat(3, minmax(180px, 1fr))", gap: 8 }, children: world.accounts.map(account => _jsx(SeatPanel, { world: world, account: account }, account)) }), _jsxs("div", { style: { display: "flex", gap: 12, flexWrap: "wrap", alignItems: "start" }, children: [_jsx("div", { style: { display: "grid", gridTemplateColumns: "repeat(3, auto)", gap: 8 }, children: pairs.map(pair => _jsx(GridTile, { world: world, viewer: pair.viewer, owner: pair.owner }, pair.viewer + "<" + pair.owner)) }), _jsx(FocusPanel, { world: world })] })] });
372
+ };
@@ -108,6 +108,9 @@ export const PeerCallVideoAudioDemo = () => {
108
108
  const audio = useMediaSource("audio", { mode: "pcm", bufferSize: 4096 });
109
109
  const canvasRef = useRef(null);
110
110
  const playerRef = useRef(null);
111
+ // the player exists only while the call is active; remember the enable intent so the
112
+ // documented order (enable -> call -> accept) produces sound without a second click
113
+ const audioWanted = useRef(false);
111
114
  const [drawn, setDrawn] = useState(0);
112
115
  const publishVideo = useMemo(() => { const send = relay.publishOf("qa-av-a"); return (frame, sentAt) => send("camera", frame, sentAt ?? Date.now()); }, [relay]);
113
116
  const publishAudio = useMemo(() => { const send = relay.publishOf("qa-av-a"); return (frame, sentAt) => send("microphone", frame, sentAt ?? Date.now()); }, [relay]);
@@ -120,9 +123,11 @@ export const PeerCallVideoAudioDemo = () => {
120
123
  const view = Media.attachVideoCanvas(watcher["qa-av-a"].camera, canvasRef.current);
121
124
  const player = Media.attachAudioPlayer(watcher["qa-av-a"].microphone, { maxBacklogSec: .35 });
122
125
  playerRef.current = player;
126
+ if (audioWanted.current)
127
+ player.enable();
123
128
  const timer = window.setInterval(() => setDrawn(view.stats().drawn), 500);
124
129
  return () => { window.clearInterval(timer); view.off(); player.off(); playerRef.current = null; };
125
130
  }, [active, relay]);
126
131
  const incoming = callee.rings.find(call => call.direction === "in");
127
- return _jsxs("div", { style: { display: "grid", gap: 8 }, children: [_jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }, children: [_jsx("button", { onClick: () => { playerRef.current?.enable(); void video.start(); void audio.start(); }, children: "enable camera + mic" }), !active && !incoming && _jsx("button", { onClick: () => caller.call("qa-av-b"), children: "call B" }), incoming && _jsx("button", { onClick: () => incoming.accept(), children: "B accept call" }), caller.active && _jsx("button", { onClick: () => caller.active?.hangup(), children: "hang up" }), _jsxs("b", { children: ["call: ", active ? "active — media attached" : incoming ? "ringing" : "idle", "; video frames: ", drawn] })] }), _jsx("canvas", { ref: canvasRef, width: 320, height: 180, style: { width: 320, height: 180, background: "#111", borderRadius: 8 } })] });
132
+ return _jsxs("div", { style: { display: "grid", gap: 8 }, children: [_jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }, children: [_jsx("button", { onClick: () => { audioWanted.current = true; playerRef.current?.enable(); void video.start(); void audio.start(); }, children: "enable camera + mic" }), !active && !incoming && _jsx("button", { onClick: () => caller.call("qa-av-b"), children: "call B" }), incoming && _jsx("button", { onClick: () => incoming.accept(), children: "B accept call" }), caller.active && _jsx("button", { onClick: () => caller.active?.hangup(), children: "hang up" }), _jsxs("b", { children: ["call: ", active ? "active — media attached" : incoming ? "ringing" : "idle", "; video frames: ", drawn] })] }), _jsx("canvas", { ref: canvasRef, width: 320, height: 180, style: { width: 320, height: 180, background: "#111", borderRadius: 8 } })] });
128
133
  };
@@ -8,3 +8,4 @@ export * from './useReorderBoard';
8
8
  export * from './useMedia';
9
9
  export * from './usePeer';
10
10
  export * from './usePeerCall';
11
+ export * from './useRoute';
@@ -8,3 +8,4 @@ export * from './useReorderBoard';
8
8
  export * from './useMedia';
9
9
  export * from './usePeer';
10
10
  export * from './usePeerCall';
11
+ export * from './useRoute';
@@ -0,0 +1,28 @@
1
+ import { Replay } from "wenay-common2";
2
+ export type RouteLogEntry = {
3
+ at: number;
4
+ from: Replay.tRouteState;
5
+ to: Replay.tRouteState;
6
+ reason?: unknown;
7
+ };
8
+ /** React binding for one route-coordinator link: live route state, the last denial or
9
+ * failure reason, connector metrics on a 500ms snapshot, and a short hand-off log.
10
+ * Route ownership stays in common2 — the hook only mirrors coordinator events; callers
11
+ * own coordinator/link lifecycle (close), exactly like usePeerCalls over CallManager. */
12
+ export declare function useRouteState<Z extends any[]>(coordinator: Pick<Replay.RouteCoordinator<Z>, "onRoute"> | null, link: Pick<Replay.RouteLink<Z>, "ref" | "state" | "reason" | "metrics"> | null): {
13
+ state: Replay.tRouteState;
14
+ reason: unknown;
15
+ metrics: {
16
+ relay: {
17
+ rtt?: number;
18
+ pending?: number;
19
+ state: Replay.tConnectorState;
20
+ } | null;
21
+ direct: {
22
+ rtt?: number;
23
+ pending?: number;
24
+ state: Replay.tConnectorState;
25
+ } | null;
26
+ } | null;
27
+ log: RouteLogEntry[];
28
+ };
@@ -0,0 +1,30 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ /** React binding for one route-coordinator link: live route state, the last denial or
3
+ * failure reason, connector metrics on a 500ms snapshot, and a short hand-off log.
4
+ * Route ownership stays in common2 — the hook only mirrors coordinator events; callers
5
+ * own coordinator/link lifecycle (close), exactly like usePeerCalls over CallManager. */
6
+ export function useRouteState(coordinator, link) {
7
+ const [state, setState] = useState(() => link?.state() ?? "closed");
8
+ const [reason, setReason] = useState(() => link?.reason());
9
+ const [metrics, setMetrics] = useState(() => link?.metrics() ?? null);
10
+ const [log, setLog] = useState([]);
11
+ useEffect(() => {
12
+ if (!coordinator || !link)
13
+ return;
14
+ setState(link.state());
15
+ setReason(link.reason());
16
+ setMetrics(link.metrics());
17
+ setLog([]);
18
+ const off = coordinator.onRoute(event => {
19
+ if (event.key !== link.ref.key)
20
+ return;
21
+ setState(event.to);
22
+ setReason(event.reason);
23
+ setMetrics(link.metrics());
24
+ setLog(current => [...current.slice(-3), { at: Date.now(), from: event.from, to: event.to, reason: event.reason }]);
25
+ });
26
+ const timer = setInterval(() => setMetrics(link.metrics()), 500);
27
+ return () => { off(); clearInterval(timer); };
28
+ }, [coordinator, link]);
29
+ return useMemo(() => ({ state, reason, metrics, log }), [state, reason, metrics, log]);
30
+ }
@@ -1,7 +1,7 @@
1
1
  import { Params } from "wenay-common2";
2
2
  export const getSettingLogs = () => ({
3
- minVarLogs: { name: "min. importance for notifications", range: { min: 0, max: 25, step: 1 }, value: 0 },
4
- minVarMessage: { name: "min. importance for log table", range: { min: 0, max: 25, step: 1 }, value: 0 },
3
+ minVarLogs: { name: "min. importance for log table", range: { min: 0, max: 25, step: 1 }, value: 0 },
4
+ minVarMessage: { name: "min. importance for notifications", range: { min: 0, max: 25, step: 1 }, value: 0 },
5
5
  timeShow: { name: "screen display time", range: { min: 1, max: 20, step: 1 }, value: 2 },
6
6
  show: { name: "show", value: true }
7
7
  });