commune v2 — signed-in players, hosted rooms, invitations, host allocation

Hub: identity now comes from the gs_session cookie riding the websocket
handshake (auth._uid_from_cookies -> username per connection; spoof-proof).
On the read-only public hub the commune requires sign-in. Rooms are hosted:
create names the room; invitations go to present, signed-in users by
username; accept/decline; leave any time (host leaving disbands); the host
allocates sections, members claim free ones (hub arbitrates conflicts); and
voice-state is relayed ONLY from the section's holder — enforced server-side.
Roominfo broadcasts keep every member's view of the room identical.

Client: invitation inbox with join/decline in the commune panel, host desk
(invite + allocate rows), identity from the signed-in account, right-click
voice items show holders ("held by X"), presence tag survives reconnects.

Verified full protocol on the live test hub: host -> invite delivered ->
accept -> roominfo both sides -> host allocates bass to peer -> peer's state
applied on host's instrument (1.44x, menu shows holder).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-06 01:01:23 +10:00
parent 7cad65125b
commit b8908504d3
2 changed files with 245 additions and 86 deletions

134
hub.py
View File

@ -133,7 +133,8 @@ class Hub:
self._scan_patches()
self.midi = _open_midi(cfg.get("midi", {}))
self.clients: set = set()
self.rooms: dict = {} # ws -> commune room name
self.cnames: dict = {} # ws -> identity (session username, or claimed on local hubs)
self.communes: dict = {} # room -> {host, members:set, invited:set, alloc:{section:user}}
self.recent_events: list[dict] = []
self._last_notes: dict[str, int] = {}
@ -320,6 +321,21 @@ class Hub:
async def ws_handler(self, ws):
self.clients.add(ws)
# who is this? the session cookie rides the websocket handshake, so a
# signed-in user's identity is known here and cannot be spoofed.
try:
hdrs = getattr(ws, "request_headers", None)
if hdrs is None and getattr(ws, "request", None) is not None:
hdrs = ws.request.headers
cookie = hdrs.get("Cookie", "") if hdrs else ""
if cookie:
import auth as _auth
uid = _auth._uid_from_cookies(cookie, _auth.DEFAULT_DB)
info = _auth.user_info(uid, _auth.DEFAULT_DB) if uid else None
if info and info.get("username"):
self.cnames[ws] = info["username"]
except Exception: # noqa
pass
try:
await ws.send(json.dumps({
"hello": "godstrument",
@ -338,22 +354,118 @@ class Hub:
try:
m = json.loads(msg)
c = m.get("commune")
if isinstance(c, dict) and c.get("room"):
# the commune: peer state relayed verbatim to roommates.
# Allowed even on the read-only public hub — it never
# touches hub state, only the other browsers in the room.
self.rooms[ws] = str(c.get("room"))[:40]
peers = [w for w in self.clients
if w is not ws and self.rooms.get(w) == self.rooms[ws]]
if peers:
websockets.broadcast(peers, msg if isinstance(msg, str) else msg.decode())
if isinstance(c, dict):
self._commune(ws, c)
continue
self._handle_cmd(m)
except Exception: # noqa
pass
finally:
self.clients.discard(ws)
self.rooms.pop(ws, None)
self.cnames.pop(ws, None)
# ---- the commune: hosted rooms, invitations, host-allocated sections ----
# Identity comes from the session cookie (public hub: signed-in users only);
# membership is by invitation with accept/decline; the host allocates
# sections; voice-state relays only from whoever actually holds the section.
def _commune(self, ws, c):
user = self.cnames.get(ws)
if self.readonly and not user:
websockets.broadcast([ws], json.dumps(
{"commune": {"error": "the commune is for signed-in players — log in first"}}))
return
if not user: # local/trusted hub: take the claimed name
user = str(c.get("from") or "guest")[:24]
self.cnames[ws] = user
room = str(c.get("room") or "")[:40].strip().lower()
R = self.communes
def out(names, obj):
targets = [w for w in self.clients if self.cnames.get(w) in names]
if targets:
websockets.broadcast(targets, json.dumps({"commune": obj}))
def roominfo(r):
g2 = R.get(r)
if g2:
out(g2["members"], {"roominfo": {"room": r, "host": g2["host"],
"members": sorted(g2["members"]), "alloc": g2["alloc"]}})
if c.get("hello"): # presence tag only
return
if c.get("create") and room:
g = R.get(room)
if g and g["members"] and user not in g["members"]:
out([user], {"error": "" + room + "” is already alive — ask its host for an invitation"})
return
R[room] = {"host": user, "members": {user}, "invited": set(), "alloc": {}}
roominfo(room)
return
g = R.get(room)
if not g:
out([user], {"error": "no such room — its host may have closed it"})
return
if c.get("invite") and user == g["host"]:
tgt = str(c["invite"]).strip()[:24]
if not any(self.cnames.get(w) == tgt for w in self.clients):
out([user], {"error": tgt + " isn't on the site right now — invitations reach the present"})
return
g["invited"].add(tgt)
out([tgt], {"invite": {"room": room, "host": user}})
out([user], {"ok": "invitation sent to " + tgt})
return
if c.get("accept"):
if user in g["invited"] or user in g["members"]:
g["invited"].discard(user)
g["members"].add(user)
roominfo(room)
else:
out([user], {"error": "no standing invitation to that room"})
return
if c.get("decline"):
g["invited"].discard(user)
out([g["host"]], {"declined": user, "room": room})
return
if user not in g["members"]:
out([user], {"error": "you are not in that room"})
return
if c.get("leave"):
g["members"].discard(user)
for s in [s for s, o in list(g["alloc"].items()) if o == user]:
del g["alloc"][s]
if user == g["host"] or not g["members"]:
out(g["members"] | {user}, {"disbanded": room})
R.pop(room, None)
else:
roominfo(room)
return
if c.get("allocate") and user == g["host"]:
a = c["allocate"] if isinstance(c["allocate"], dict) else {}
s, to = str(a.get("section", ""))[:40], str(a.get("to", ""))[:24]
if s and to in g["members"]:
g["alloc"][s] = to
roominfo(room)
return
if c.get("claim"):
s = str(c["claim"])[:40]
held = g["alloc"].get(s)
if held and held != user and user != g["host"]:
out([user], {"error": "that section is held by " + held + " — ask the host"})
else:
g["alloc"][s] = user
roominfo(room)
return
if c.get("release"):
s = str(c["release"])[:40]
if g["alloc"].get(s) == user:
del g["alloc"][s]
roominfo(room)
return
if c.get("voice") and c.get("state") is not None:
s = str(c["voice"])[:40]
if g["alloc"].get(s) == user: # only the section's holder speaks for it
out(g["members"] - {user}, {"voice": s, "state": c["state"], "from": user, "room": room})
return
# ---- named templates (patches) ------------------------------------
def _scan_patches(self):

View File

@ -898,7 +898,7 @@
<li><b>The groove grids — sequence the voices.</b> Open any voice's tab (shift-click it) and there's a <b>16-step grid on the godtime clock</b>. The note voices (<i>lead, bass</i>) get a <b>mini piano roll</b> — eight rows of minor pentatonic, click to place a note, click again to clear — and while your groove is on, <i>your melody replaces the data's</i>: bitcoin lets go of the lead and you take it. Every other voice gets an <b>808-style gate row</b> that rhythmically chops the world's signal — the reverb, the grit, the percussion breathing in your pattern while the planet still decides how hard. Grooves ride the godtime tempo (lock the dial, or let your heart drive it), and they <b>save with your patch</b>. And the <b>sources</b> get the grid too, with a different power: <b>sample &amp; hold</b> — while a source's groove is on, the world's number is only <i>sampled</i> on your lit steps and held between them, so a smooth drifting feed becomes stepped, rhythmic, quantized in time. Bitcoin arpeggiating in your pattern. The wind as a sequence. If an out port is chosen in ⚙, godtime also sends <b>MIDI clock</b> (24 ppqn), so a DAW can slave its tempo to the planet — or to your pulse. And the grids play <i>outward</i> too: with an out port chosen, <b>piano-roll notes go out as real MIDI</b> on the lead and bass channels (each note one sixteenth long), and the <b>mind-mode console plays on its own third channel</b> — so your grooves and your chakra-scale jams drive Ableton, Strudel, or hardware exactly as they drive the built-in synth. The three mouths drink at once: ♪ for the browser synth, the out port for MIDI, both or neither — there is no switch, only where you point the sound.</li>
<li><b>Press <kbd>T</kbd> — tracks, for making actual songs.</b> A conventional tracks-and-clips view for anyone who finds the cathedral overwhelming: every voice is a <b>track</b>, eight <b>bars</b> run across, and each cell cycles <b>● live</b> (the world plays that lane) → <b>A</b><b>B</b><b>C</b> (clip patterns on the groove grids — A and B come pre-seeded so it makes music immediately) → <b>silent</b>. Double-click any clip to edit its groove in the piano roll. Press <b></b> and the arrangement loops on the godtime clock — the planet still plays <i>through</i> your song, because live lanes stay wired to the world. Arrangements <b>save with your patch</b>, play out over MIDI like everything else, and <b>export .mid</b> hands you a Standard MIDI File of the note lanes to drag into Ableton, Logic, anything. Not a DAW — a sketchpad where the world sits in on your session. The panel earns its keep like one, too: a <b>🎛 tracks button</b> sits bottom-right always; <b>drag the panel's top edge to resize</b> (remembered); the top bar <b>tabs between clips and grooves</b> — the grooves tab is a full-size pattern editor (pick a track, pick a letter, big piano roll or 16 steps; double-clicking any clip jumps you straight there); and <b>right-click any track name for that voice's full routing menu</b> — every cable, every level, patch and unpatch without leaving the panel.</li>
<li><b>📽 The stage — send the visual to any screen.</b> Right-click the sky → <b>the stage</b> and a clean black mirror window opens, fed live from the canvas. Drag it to a projector, an AirPlay display, a second monitor — macOS treats it like any window — and <b>double-click for fullscreen</b>. The stage has a <b>camera</b>: the whole view by default (whatever you're in — a skin, the wheel, mind mode's POV), or right-click any node → <b>project this node</b> and the camera glides after that one sphere through its orbits, or <b>project its station</b> to frame a whole wheel and its satellites. Change cameras live from the menus while the projector runs; the audience sees the camera breathe from subject to subject.</li>
<li><b>🕸 The commune — play together.</b> Right-click the sky → <b>the commune</b>, agree on a room word with your friends (any word — though the word <i>is</i> the key, so pick an uncommon one), and join. No pairing, no Bluetooth, no setup: everyone on the site is already connected to the same hub, and the hub simply carries your hands to each other — same living room or opposite sides of the earth. Then <b>claim your sections</b>: right-click any voice → <i>claim this voice</i>, and from that moment your levels, mutes, grooves and arranger lanes for it follow your hands on everyone's screen. One of you takes the bass and the fires, another the pads and the moon, and someone <b>claims the godtime itself</b> and conducts. Claims are visible to all, take-overs are announced, and the world — the Source — stays identical for everyone, because it always was. Three people, one planet, one instrument.</li></ul>
<li><b>🕸 The commune — play together, properly.</b> Right-click the sky → <b>the commune</b>. This is for <b>signed-in players</b>: your session rides the connection, so the hub knows exactly who everyone is — no impostors, no setup, no Bluetooth. One of you <b>hosts</b>: name the room, then <b>✉ invite</b> the others by their username (they must be on the site — invitations reach the present). The invited see the invitation arrive live and <b>join or decline</b>; anyone may <b>leave</b> whenever they wish, and if the host leaves, the room closes with them. Then the sections: members <b>claim voices</b> (right-click any voice, or the panel's picker), or the <b>host allocates</b> them — this bass to her, those pads to him, <b>the godtime itself</b> to whoever conducts — and the hub enforces it: only the section's holder can speak for it. From that moment your levels, mutes, grooves and arranger lanes for what you hold follow your hands on every screen, same living room or opposite sides of the earth. The world — the Source — stays identical for everyone, because it always was. Three people, one planet, one instrument.</li></ul>
<div class="hr"></div>
@ -1512,32 +1512,31 @@
// between roommates. Form a room with a word, claim voices (and the godtime),
// and each player's owned sections follow their hands on every screen —
// same living room or opposite sides of the earth.
const commune = { room: null, me: null, peers: {}, owners: {}, sent: {} };
const commune = { room: null, host: null, members: [], alloc: {}, invites: {}, sent: {} };
function communeMe() {
const u = (typeof Accounts !== "undefined" && Accounts.user) ? Accounts.user.username : null;
if (u) return u;
const chip2 = document.getElementById("authchip");
return chip2 && /^@/.test(chip2.textContent) ? chip2.textContent.slice(1) : "guest";
}
function communeSend(obj) {
if (!commune.room) return;
sendCmd({ commune: Object.assign({ room: commune.room, from: commune.me }, obj) });
sendCmd({ commune: Object.assign({ room: obj.room != null ? obj.room : commune.room, from: communeMe() }, obj) });
}
function communeJoin(room, name) {
commune.room = (room || "").trim().toLowerCase();
commune.me = (name || "").trim() || ("guest" + Math.floor(Math.random() * 900 + 100));
if (!commune.room) return;
commune.peers = {}; commune.owners = {}; commune.sent = {};
communeSend({ join: 1 });
eventTicker.unshift({ text: "🕸 you joined the commune “" + commune.room + "” as " + commune.me, tAdded: now(), src: "sys" });
function communeCreate(name) {
const room = (name || "").trim().toLowerCase();
if (room) communeSend({ create: 1, room });
}
function communeInvite(who) { communeSend({ invite: (who || "").trim() }); }
function communeAccept(room) { communeSend({ accept: 1, room }); }
function communeDecline(room) { communeSend({ decline: 1, room }); delete commune.invites[room]; }
function communeLeave() {
if (commune.room) communeSend({ leave: 1 });
commune.room = null; commune.peers = {}; commune.owners = {}; commune.sent = {};
}
function communeClaim(section) {
commune.owners[section] = commune.me;
delete commune.sent[section]; // force a fresh broadcast
communeSend({ claim: section });
}
function communeRelease(section) {
if (commune.owners[section] === commune.me) delete commune.owners[section];
communeSend({ release: section });
commune.room = null; commune.host = null; commune.members = []; commune.alloc = {}; commune.sent = {};
}
function communeAllocate(section, to) { communeSend({ allocate: { section, to } }); }
function communeClaim(section) { delete commune.sent[section]; communeSend({ claim: section }); }
function communeRelease(section) { communeSend({ release: section }); }
setInterval(() => { if (connected) communeSend({ hello: 1, room: "~" }); }, 15000); // presence tag survives reconnects
function communeState(section) { // the shareable state of a section
if (section === "@godtime") return { bpm: Math.round(godtime.bpm * 2) / 2 };
const t2 = tweaks[section] || { mute: 0, gain: 1, offset: 0 };
@ -1549,37 +1548,48 @@
lane: arr.grid[section] ? arr.grid[section].slice() : null,
};
}
setInterval(() => { // owned sections sync, diff-only
setInterval(() => { // your sections sync, diff-only
if (!commune.room) return;
for (const s2 in commune.owners) {
if (commune.owners[s2] !== commune.me) continue;
for (const s2 in commune.alloc) {
if (commune.alloc[s2] !== communeMe()) continue;
const st = communeState(s2), j2 = JSON.stringify(st);
if (commune.sent[s2] !== j2) { commune.sent[s2] = j2; communeSend({ voice: s2, state: st }); }
}
}, 600);
setInterval(() => { if (commune.room) communeSend({ join: 1 }); }, 5000); // heartbeat
function handleCommune(c) {
if (!commune.room || c.room !== commune.room || !c.from || c.from === commune.me) return;
if (c.join) {
const fresh = !commune.peers[c.from];
commune.peers[c.from] = now();
if (fresh) {
eventTicker.unshift({ text: "🕸 " + c.from + " is in the commune", tAdded: now(), src: "sys" });
for (const s2 in commune.owners) // tell the newcomer what you hold
if (commune.owners[s2] === commune.me) { delete commune.sent[s2]; communeSend({ claim: s2 }); }
if (c.error) { eventTicker.unshift({ text: "🕸 " + c.error, tAdded: now(), src: "sys" }); return; }
if (c.ok) { eventTicker.unshift({ text: "🕸 " + c.ok, tAdded: now(), src: "sys" }); return; }
if (c.invite) { // someone asks you in
commune.invites[c.invite.room] = c.invite.host;
eventTicker.unshift({ text: "✉ " + c.invite.host + " invites you to the commune “" + c.invite.room + "” — right-click the sky → the commune", tAdded: now(), src: "sys" });
if (communeEl.classList.contains("open")) openCommune();
return;
}
if (c.declined) { eventTicker.unshift({ text: "🕸 " + c.declined + " declined the invitation", tAdded: now(), src: "sys" }); return; }
if (c.disbanded) {
if (commune.room === c.disbanded) {
commune.room = null; commune.host = null; commune.members = []; commune.alloc = {}; commune.sent = {};
eventTicker.unshift({ text: "🕸 the commune “" + c.disbanded + "” has closed", tAdded: now(), src: "sys" });
if (communeEl.classList.contains("open")) openCommune();
}
return;
}
if (c.leave) { delete commune.peers[c.from]; return; }
if (c.claim) {
if (commune.owners[c.claim] === commune.me)
eventTicker.unshift({ text: "🕸 " + c.from + " took " + (c.claim === "@godtime" ? "the godtime" : prettyDest(c.claim)), tAdded: now(), src: "sys" });
commune.owners[c.claim] = c.from;
if (c.roominfo) { // the hub is the truth of the room
const ri = c.roominfo, before = commune.members;
const joining = !commune.room;
commune.room = ri.room; commune.host = ri.host;
commune.members = ri.members || []; commune.alloc = ri.alloc || {};
delete commune.invites[ri.room];
if (!joining) for (const m2 of commune.members)
if (before.indexOf(m2) < 0)
eventTicker.unshift({ text: "🕸 " + m2 + " joined the commune", tAdded: now(), src: "sys" });
if (joining)
eventTicker.unshift({ text: "🕸 you are in the commune “" + ri.room + "” · host: " + ri.host, tAdded: now(), src: "sys" });
if (communeEl.classList.contains("open")) openCommune();
return;
}
if (c.release) { if (commune.owners[c.release] === c.from) delete commune.owners[c.release]; return; }
if (c.voice && c.state && commune.owners[c.voice] === c.from) {
if (c.voice && c.state && c.room === commune.room && commune.alloc[c.voice] === c.from && c.from !== communeMe()) {
const st = c.state;
if (c.voice === "@godtime") { if (st.bpm) godtime.bpm = clamp(st.bpm, 40, 180); return; }
const n = dests.get(c.voice);
@ -1604,32 +1614,7 @@
const x2 = document.createElement("span"); x2.className = "close"; x2.textContent = "×";
x2.onclick = closeCommunePanel; communeEl.appendChild(x2);
const h3 = document.createElement("h3"); h3.textContent = "🕸 the commune"; communeEl.appendChild(h3);
const sub = document.createElement("div"); sub.className = "sub";
sub.textContent = "form a room with any word — everyone who joins it plays one instrument together. claim voices from their right-click menus or below; what you claim follows your hands on every screen.";
communeEl.appendChild(sub);
if (!commune.room) {
const roomI = document.createElement("input"); roomI.placeholder = "room word (e.g. coven)";
const nameI = document.createElement("input"); nameI.placeholder = "your name";
const chip2 = document.getElementById("authchip");
if (chip2 && /^@/.test(chip2.textContent)) nameI.value = chip2.textContent.slice(1);
const r1 = document.createElement("div"); r1.className = "hrow"; r1.appendChild(roomI);
const r2 = document.createElement("div"); r2.className = "hrow"; r2.appendChild(nameI);
const go = document.createElement("button"); go.className = "midibtn"; go.style.marginTop = "8px";
go.textContent = "join the commune";
go.onclick = () => { communeJoin(roomI.value, nameI.value); openCommune(); };
communeEl.appendChild(r1); communeEl.appendChild(r2); communeEl.appendChild(go);
} else {
const inRoom = document.createElement("div"); inRoom.className = "sub";
const names = [commune.me + " (you)"].concat(Object.keys(commune.peers));
inRoom.innerHTML = "room: <b style='color:#cfe0f5'>" + commune.room + "</b> · here: " + names.join(", ");
communeEl.appendChild(inRoom);
const claims = document.createElement("div"); claims.className = "sub";
const held = Object.keys(commune.owners);
claims.textContent = held.length
? "claims: " + held.map(s2 => (s2 === "@godtime" ? "godtime" : prettyDest(s2)) + " → " + commune.owners[s2]).join(" · ")
: "no voices claimed yet — right-click a voice, or use the picker below";
communeEl.appendChild(claims);
const pick = document.createElement("div"); pick.className = "hrow";
const mkVoiceSel = () => {
const sel = document.createElement("select"); sel.className = "asel"; sel.style.flex = "1";
const og = document.createElement("option"); og.value = "@godtime"; og.textContent = "the godtime (tempo)";
sel.appendChild(og);
@ -1637,12 +1622,73 @@
const o2 = document.createElement("option"); o2.value = k2; o2.textContent = prettyDest(k2);
sel.appendChild(o2);
}
return sel;
};
if (!commune.room) {
const sub = document.createElement("div"); sub.className = "sub";
sub.textContent = "host a room and invite the others (they must be signed in and on the site), or accept an invitation below. the host names the room and can allocate sections; anyone may leave at any time.";
communeEl.appendChild(sub);
// pending invitations
const invRooms = Object.keys(commune.invites);
for (const rm of invRooms) {
const row = document.createElement("div"); row.className = "hrow";
const lbl = document.createElement("span"); lbl.style.flex = "1";
lbl.innerHTML = "✉ <b style='color:#cfe0f5'>" + commune.invites[rm] + "</b> invites you to “" + rm + "”";
const acc = document.createElement("button"); acc.className = "midibtn"; acc.textContent = "join";
acc.onclick = () => communeAccept(rm);
const dec = document.createElement("button"); dec.className = "midibtn"; dec.textContent = "decline";
dec.onclick = () => { communeDecline(rm); openCommune(); };
row.appendChild(lbl); row.appendChild(acc); row.appendChild(dec);
communeEl.appendChild(row);
}
const roomI = document.createElement("input"); roomI.placeholder = "name your room (e.g. the lodge)";
const r1 = document.createElement("div"); r1.className = "hrow"; r1.appendChild(roomI);
const go = document.createElement("button"); go.className = "midibtn"; go.style.marginTop = "8px";
go.textContent = "host a commune";
go.onclick = () => communeCreate(roomI.value);
communeEl.appendChild(r1); communeEl.appendChild(go);
} else {
const isHost = commune.host === communeMe();
const inRoom = document.createElement("div"); inRoom.className = "sub";
inRoom.innerHTML = "room: <b style='color:#cfe0f5'>" + commune.room + "</b> · host: <b style='color:#cfe0f5'>" +
commune.host + "</b><br>here: " + commune.members.map(m2 => m2 === communeMe() ? m2 + " (you)" : m2).join(", ");
communeEl.appendChild(inRoom);
const claims = document.createElement("div"); claims.className = "sub";
const held = Object.keys(commune.alloc);
claims.textContent = held.length
? "sections: " + held.map(s2 => (s2 === "@godtime" ? "godtime" : prettyDest(s2)) + " → " + commune.alloc[s2]).join(" · ")
: "no sections held yet — claim below, or the host can allocate";
communeEl.appendChild(claims);
// claim (anyone)
const pick = document.createElement("div"); pick.className = "hrow";
const sel = mkVoiceSel();
const cb = document.createElement("button"); cb.className = "midibtn"; cb.textContent = "claim";
cb.onclick = () => { communeClaim(sel.value); openCommune(); };
cb.onclick = () => communeClaim(sel.value);
pick.appendChild(sel); pick.appendChild(cb);
communeEl.appendChild(pick);
if (isHost) {
// invite (host)
const invRow = document.createElement("div"); invRow.className = "hrow";
const invI = document.createElement("input"); invI.placeholder = "invite a signed-in user…";
const ib = document.createElement("button"); ib.className = "midibtn"; ib.textContent = "✉ invite";
ib.onclick = () => { communeInvite(invI.value); invI.value = ""; };
invRow.appendChild(invI); invRow.appendChild(ib);
communeEl.appendChild(invRow);
// allocate (host)
const alRow = document.createElement("div"); alRow.className = "hrow";
const alSel = mkVoiceSel();
const whoSel = document.createElement("select"); whoSel.className = "asel";
for (const m2 of commune.members) {
const o2 = document.createElement("option"); o2.value = m2; o2.textContent = m2;
whoSel.appendChild(o2);
}
const ab = document.createElement("button"); ab.className = "midibtn"; ab.textContent = "allocate";
ab.onclick = () => communeAllocate(alSel.value, whoSel.value);
alRow.appendChild(alSel); alRow.appendChild(whoSel); alRow.appendChild(ab);
communeEl.appendChild(alRow);
}
const lv = document.createElement("button"); lv.className = "midibtn"; lv.style.marginTop = "8px";
lv.textContent = "leave the room";
lv.textContent = isHost ? "disband the commune" : "leave the room";
lv.onclick = () => { communeLeave(); openCommune(); };
communeEl.appendChild(lv);
}
@ -4382,9 +4428,10 @@
ctxItem("📽 project its " + activeSkin.word,
() => openStage({ type: "station", key: node.station || activeSkin.stationOf(node.key) }));
if (!isSrc && commune.room) {
const owner = commune.owners[node.key];
if (owner === commune.me) ctxItem("🕸 release this voice", () => communeRelease(node.key));
else ctxItem(owner ? "🕸 take this voice from " + owner : "🕸 claim this voice (you play it)",
const owner = commune.alloc[node.key];
if (owner === communeMe()) ctxItem("🕸 release this voice", () => communeRelease(node.key));
else ctxItem(owner ? "🕸 held by " + owner + (commune.host === communeMe() ? " — reassign to you" : " — ask the host")
: "🕸 claim this voice (you play it)",
() => communeClaim(node.key));
}
const hint = document.createElement("div"); hint.className = "hint";
@ -4432,10 +4479,10 @@
ctxItem("🕸 the commune…" + (commune.room ? " (" + commune.room + ")" : ""),
() => openCommune());
if (commune.room) {
const gOwner = commune.owners["@godtime"];
ctxItem(gOwner === commune.me ? "🕸 release the godtime" :
(gOwner ? "🕸 take the godtime from " + gOwner : "🕸 claim the godtime (tempo)"),
() => { gOwner === commune.me ? communeRelease("@godtime") : communeClaim("@godtime"); });
const gOwner = commune.alloc["@godtime"];
ctxItem(gOwner === communeMe() ? "🕸 release the godtime" :
(gOwner ? "🕸 godtime held by " + gOwner : "🕸 claim the godtime (tempo)"),
() => { gOwner === communeMe() ? communeRelease("@godtime") : communeClaim("@godtime"); });
}
ctxSep();
ctxItem(stageWin ? "📽 stage: whole view" : "📽 the stage — project this view",