jevstrudel.git / website / src / jev / lobby.mjs
lobby.mjsannotatedlobby.mjssource324 lines · 11.4 KB · raw

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';
29export const LOBBY_PATH = '/jev/lobby';
30const VISIBLE = 'jevstrudel.lobby.visible';
31const TICK_MS = 1000;

Resent even without a change, so a tab's progress never drifts far on other pages.

34const REFRESH_MS = 30_000;
35const LISTEN_TIMEOUT_MS = 15_000;

── 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).

83export function cycleNow(tab, roomNow) {
84  if (!tab.playing || tab.cycle === null) return null;
85  return tab.cycle + Math.max(0, (roomNow - tab.at) / 1000) * (tab.cps ?? 0);
86}

How far through its song a tab is, 0 to 1, or null when not known.

89export function progressOf(tab, roomNow) {
90  const c = cycleNow(tab, roomNow);
91  const end = tab.length ?? null;
92  return c === null || !end ? null : Math.min(1, c / end);
93}

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).

131export const isShown = () => state.visible;
132setListedCheck(isShown);

── the connection ────────────────────────────────

135let editorRef = null;
136let socket = null;
137let retry = null;
138let retryMs = 1000;
139let ticker = null;
140let last = null;
141let lastAt = 0;
142let pendingTimer = null;
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}
307export function setVisible(on) {
308  try {
309    localStorage.setItem(VISIBLE, on ? 'on' : 'off');
310  } catch {}
311  set({ visible: on });
312  tick(true);
313  // a party this tab hosts is listed with it
314  listedChanged();
315}

useReplContext, once the editor exists.

318export function initLobby(ref) {
319  editorRef = ref;
320  if (typeof WebSocket === 'undefined' || typeof location === 'undefined' || socket) return;
321  connect();
322  clearInterval(ticker);
323  ticker = setInterval(() => tick(), TICK_MS);
324}