From b8908504d3a90ba5696702967fdcdec7238f5911 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 6 Jul 2026 01:01:23 +1000 Subject: [PATCH] =?UTF-8?q?commune=20v2=20=E2=80=94=20signed-in=20players,?= =?UTF-8?q?=20hosted=20rooms,=20invitations,=20host=20allocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- hub.py | 134 ++++++++++++++++++++++++++++++--- viz/index.html | 197 ++++++++++++++++++++++++++++++------------------- 2 files changed, 245 insertions(+), 86 deletions(-) diff --git a/hub.py b/hub.py index e37dc4b..db54fdd 100644 --- a/hub.py +++ b/hub.py @@ -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): diff --git a/viz/index.html b/viz/index.html index 33ad3bb..b6dc335 100644 --- a/viz/index.html +++ b/viz/index.html @@ -898,7 +898,7 @@
  • The groove grids — sequence the voices. Open any voice's tab (shift-click it) and there's a 16-step grid on the godtime clock. The note voices (lead, bass) get a mini piano roll — eight rows of minor pentatonic, click to place a note, click again to clear — and while your groove is on, your melody replaces the data's: bitcoin lets go of the lead and you take it. Every other voice gets an 808-style gate row 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 save with your patch. And the sources get the grid too, with a different power: sample & hold — while a source's groove is on, the world's number is only sampled 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 MIDI clock (24 ppqn), so a DAW can slave its tempo to the planet — or to your pulse. And the grids play outward too: with an out port chosen, piano-roll notes go out as real MIDI on the lead and bass channels (each note one sixteenth long), and the mind-mode console plays on its own third channel — 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.
  • Press T — tracks, for making actual songs. A conventional tracks-and-clips view for anyone who finds the cathedral overwhelming: every voice is a track, eight bars run across, and each cell cycles ● live (the world plays that lane) → ABC (clip patterns on the groove grids — A and B come pre-seeded so it makes music immediately) → silent. Double-click any clip to edit its groove in the piano roll. Press and the arrangement loops on the godtime clock — the planet still plays through your song, because live lanes stay wired to the world. Arrangements save with your patch, play out over MIDI like everything else, and export .mid 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 🎛 tracks button sits bottom-right always; drag the panel's top edge to resize (remembered); the top bar tabs between clips and grooves — 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 right-click any track name for that voice's full routing menu — every cable, every level, patch and unpatch without leaving the panel.
  • 📽 The stage — send the visual to any screen. Right-click the sky → the stage 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 double-click for fullscreen. The stage has a camera: the whole view by default (whatever you're in — a skin, the wheel, mind mode's POV), or right-click any node → project this node and the camera glides after that one sphere through its orbits, or project its station 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.
  • -
  • 🕸 The commune — play together. Right-click the sky → the commune, agree on a room word with your friends (any word — though the word is 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 claim your sections: right-click any voice → claim this voice, 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 claims the godtime itself 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.
  • +
  • 🕸 The commune — play together, properly. Right-click the sky → the commune. This is for signed-in players: your session rides the connection, so the hub knows exactly who everyone is — no impostors, no setup, no Bluetooth. One of you hosts: name the room, then ✉ invite the others by their username (they must be on the site — invitations reach the present). The invited see the invitation arrive live and join or decline; anyone may leave whenever they wish, and if the host leaves, the room closes with them. Then the sections: members claim voices (right-click any voice, or the panel's picker), or the host allocates them — this bass to her, those pads to him, the godtime itself 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.
  • @@ -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: " + commune.room + " · 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 = "✉ " + commune.invites[rm] + " 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: " + commune.room + " · host: " + + commune.host + "
    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",