Who is listening now (worker/src/lobby-room.ts has the room's side): every page joins the site's lobby and says what its tab is doing, and hears the same of every other visible tab, for the jev panel's list and the header's 👥 count (Lobby.jsx).
A tab is visible by default, since the site is meant to be heard together; "show me here" (kept per browser) hides it, and a hidden tab is sent to nobody. Who a tab is comes from the Worker, from the session: an account's display name, or, signed out, nothing, which pages show as "a listener". The page never says who it is.
Listening along is a listening party (party.mjs): the listener's page asks the room to listen to a tab; that tab starts a party on its song, as "👥 listen together" does, and sends its room to the listener's page only, which joins it as a guest and plays in sync, with the host's Jev. Only a site song can be listened along to (a guest plays it from its own build, by id); a listener's song is opened from the top, in the sandbox, and someone's own code is only listed.
Everything from the room is other people's data: ids and text, checked by the room and shown only as text.
22import { nowPlaying, readSongs } from './client.mjs'; 23import { LISTENER_PREFIX } from './listeners.mjs'; 24import { boothStore, playing as sectionAt } from './boothStore.mjs'; 25import { endCycle } from './nextTrack.mjs'; 26import { joinParty, listedChanged, partyStore, setListedCheck, startParty } from './party.mjs'; 27import { sandbox, sandboxCycle } from './sandbox.mjs';
Resent even without a change, so a tab's progress never drifts far on other pages.
── pure: what this tab says, and how others' progress is read ────────
38const round = (x) => (typeof x === 'number' ? Math.round(x * 10) / 10 : null);
What this tab is doing, as a status message for the room. foreign is
the sandbox's provenance (a listener's song: { song }), booth the
page's booth, cycle the playhead (the page's or the sandbox's).
43export function statusFor({ songs, nowId, code, foreign, started, cycle, cps, booth, visible }) { 44 const song = !foreign ? songs.find((s) => s.id === nowId && s.code) : null; 45 const listener = foreign?.song?.id?.startsWith(LISTENER_PREFIX) ? foreign.song : null; 46 const playing = Boolean(started) && typeof cycle === 'number'; 47 const asWritten = Boolean(song) && code === song.code; 48 const at = playing && booth ? sectionAt(booth, cycle) : null; 49 const section = at 50 ? (booth.form && at.decision.values?.[booth.form.name]?.choice) || `section ${at.segment + 1}` 51 : null; 52 return { 53 t: 'status', 54 visible, 55 song: song?.id ?? null, 56 edited: Boolean(song) && !asWritten, 57 listener: listener ? listener.id.slice(LISTENER_PREFIX.length) : null, 58 title: listener?.title ?? null, 59 playing, 60 cycle: playing ? round(cycle) : null, 61 cps: playing ? cps : null, 62 // how long the song lasts, once known; while Jev walks its form, the most it can 63 length: asWritten ? endCycle(song, booth) : null, 64 max: booth && Number.isFinite(booth.segments) ? booth.segments * booth.every : null, 65 section: typeof section === 'string' ? section : null, 66 }; 67}
Whether a new status is worth sending: anything but the playhead changed, or the playhead is off from where the last status predicts.
71export function worthSending(last, next, elapsedMs) { 72 if (!last) return true; 73 const { cycle: a, ...restA } = last; 74 const { cycle: b, ...restB } = next; 75 if (JSON.stringify(restA) !== JSON.stringify(restB)) return true; 76 if (a === null || b === null) return a !== b; 77 if (elapsedMs >= REFRESH_MS) return true; 78 return Math.abs(a + (elapsedMs / 1000) * (next.cps ?? 0) - b) > 1; 79}
Where a tab is now: its cycle when the room heard it, moved on at its
tempo, in the room's clock (roomNow).
How far through its song a tab is, 0 to 1, or null when not known.
Tabs by who: an account's together, each visitor who is not signed in on their own. Most recently heard first.
97export function groupTabs(tabs) { 98 const groups = new Map(); 99 for (const tab of [...tabs].sort((a, b) => b.at - a.at)) { 100 const g = groups.get(tab.group) ?? { group: tab.group, name: tab.name, account: tab.account ?? null, tabs: [] }; 101 g.tabs.push(tab); 102 groups.set(tab.group, g); 103 } 104 return [...groups.values()]; 105}
── the store the UI reads ────────────────────────
108let visible = (() => { 109 try { 110 return localStorage.getItem(VISIBLE) !== 'off'; 111 } catch { 112 return true; 113 } 114})(); 115let state = { status: 'off', id: null, n: 0, tabs: [], offset: 0, visible, pending: null, error: null }; 116const listeners = new Set(); 117function set(patch) { 118 state = { ...state, ...patch }; 119 for (const l of listeners) l(); 120} 121export const lobbyStore = { 122 subscribe(l) { 123 listeners.add(l); 124 return () => listeners.delete(l); 125 }, 126 get: () => state, 127}; 128export const roomNow = () => Date.now() + state.offset;
Whether this page shows itself to others ("show me here"): a party it hosts is then listed to join too (party.mjs, the listening tab).
── the connection ────────────────────────────────
144function send(message) { 145 if (socket?.readyState === 1) socket.send(JSON.stringify(message)); 146} 147 148function currentStatus() { 149 const editor = editorRef?.current; 150 const scheduler = editor?.repl?.scheduler; 151 const sb = sandbox.get(); 152 const inSandbox = sb.status === 'playing'; 153 let cycle = null; 154 try { 155 cycle = inSandbox ? sandboxCycle() : scheduler?.started ? scheduler.now() : null; 156 } catch { 157 cycle = null; 158 } 159 return statusFor({ 160 songs: readSongs(), 161 nowId: nowPlaying.get(), 162 code: editor?.code ?? '', 163 foreign: sandbox.foreign(), 164 started: inSandbox || Boolean(scheduler?.started), 165 cycle, 166 cps: inSandbox ? sb.cps : (scheduler?.cps ?? null), 167 booth: boothStore.getSnapshot().booth, 168 visible: state.visible, 169 }); 170} 171 172function tick(force = false) { 173 const next = currentStatus(); 174 const now = Date.now(); 175 if (!force && !worthSending(last, next, now - lastAt)) return; 176 send(next); 177 last = next; 178 lastAt = now; 179} 180 181function connect() { 182 clearTimeout(retry); 183 const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; 184 const ws = new WebSocket(`${protocol}//${location.host}${LOBBY_PATH}`); 185 socket = ws; 186 set({ status: 'connecting' }); 187 ws.onopen = () => { 188 retryMs = 1000; 189 send({ t: 'ping', c: Date.now() }); 190 }; 191 ws.onmessage = (e) => { 192 let m; 193 try { 194 m = JSON.parse(e.data); 195 } catch { 196 return; 197 } 198 if (socket === ws) onMessage(m); 199 }; 200 ws.onclose = (e) => { 201 if (socket !== ws) return; 202 socket = null; 203 // 4xxx: refused (the lobby is full); a static host without the Worker 204 // fails to connect at all: either way the page works without it 205 if (e.code >= 4000 && e.code < 5000) return set({ status: 'off', tabs: [] }); 206 set({ status: 'reconnecting' }); 207 retry = setTimeout(connect, retryMs); 208 retryMs = Math.min(retryMs * 2, 60_000); 209 }; 210} 211 212const TAB_FIELDS = ['id', 'name', 'account', 'group', 'song', 'listener', 'title', 'edited', 'playing', 'cycle', 'cps', 'at', 'length', 'max', 'section'];
Only the fields the room sends, whatever else arrives.
214const ACCOUNT = /^[A-Za-z0-9_-]{22}$/; 215const tabOf = (t) => { 216 if (!t || typeof t.id !== 'string') return null; 217 const tab = Object.fromEntries(TAB_FIELDS.map((k) => [k, t[k] ?? null])); 218 // an account id opens a profile: only one that looks like one 219 if (typeof tab.account !== 'string' || !ACCOUNT.test(tab.account)) tab.account = null; 220 return tab; 221};
223function onMessage(m) { 224 switch (m.t) { 225 case 'welcome': 226 last = null; 227 set({ 228 status: 'open', 229 id: m.id, 230 n: m.n, 231 tabs: (m.tabs ?? []).map(tabOf).filter(Boolean), 232 offset: typeof m.now === 'number' ? m.now - Date.now() : 0, 233 }); 234 tick(true); 235 return; 236 case 'pong': 237 // the room's clock, from the round trip's midpoint 238 if (typeof m.c === 'number' && typeof m.s === 'number') set({ offset: m.s - (m.c + Date.now()) / 2 }); 239 return; 240 case 'count': 241 set({ n: m.n }); 242 return; 243 case 'tab': { 244 const tab = tabOf(m.tab); 245 if (!tab) return; 246 set({ tabs: [...state.tabs.filter((t) => t.id !== tab.id), tab] }); 247 return; 248 } 249 case 'gone': 250 set({ tabs: state.tabs.filter((t) => t.id !== m.id) }); 251 return; 252 case 'listen': 253 invite(m.from); 254 return; 255 case 'join': 256 if (state.pending && m.from === state.pending) { 257 clearTimeout(pendingTimer); 258 set({ pending: null, error: null }); 259 joinParty(m.room); 260 } 261 return; 262 case 'error': 263 if (m.to && m.to === state.pending) { 264 clearTimeout(pendingTimer); 265 set({ pending: null, error: m.why }); 266 } 267 return; 268 } 269}
Someone wants to listen along: send them, through the lobby, the room of the party this tab is in (hosting, or itself listening along), or start one on its song. A new party's room is sent once this tab is in it: a guest who arrives at an empty room is turned away (party-room.ts, admit).
275const INVITE_WAIT_MS = 10_000; 276function invite(from) { 277 const party = partyStore.get(); 278 if (party.status === 'open') return send({ t: 'invite', to: from, room: party.room }); 279 const room = party.status === 'off' || party.status === 'ended' ? startParty() : party.room; 280 if (!room) return; 281 const done = () => { 282 unsubscribe(); 283 clearTimeout(timer); 284 }; 285 const unsubscribe = partyStore.subscribe(() => { 286 const now = partyStore.get(); 287 if (now.room !== room) return done(); 288 if (now.status === 'open') { 289 done(); 290 send({ t: 'invite', to: from, room }); 291 } 292 }); 293 const timer = setTimeout(done, INVITE_WAIT_MS); 294}
── what the UI calls ─────────────────────────────
297export function listenAlong(tab) { 298 if (!tab?.song || !tab.playing || state.pending) return; 299 set({ pending: tab.id, error: null }); 300 send({ t: 'listen', to: tab.id }); 301 clearTimeout(pendingTimer); 302 pendingTimer = setTimeout(() => { 303 if (state.pending === tab.id) set({ pending: null, error: 'they did not answer' }); 304 }, LISTEN_TIMEOUT_MS); 305}