jevstrudel.git / website / src / jev / party.mjs
party.mjsannotatedparty.mjssource519 lines · 18.4 KB · raw
1// Listening parties: a host plays a song, guests who open its link hear the
2// same song with the same Jev decisions, in time with the host.
3//
4// One room per party, a Durable Object in the site's Worker
5// (worker/src/party-room.ts has the protocol). The host's page is the only
6// one that asks Jev: it announces each performance (`run`: the song), every
7// decision its jev()s settle (`decision`, from jevCore's watchDecisions) and
8// when its scheduler started (`clock`, in the room's clock). A guest's
9// jev()s follow those decisions (jevCore's followDecisions) and its
10// scheduler starts at the cycle the host is on, so a guest joining mid-song
11// starts mid-song. 🔥/😴 from anyone go to the room, which counts them and
12// tells everyone the totals, so the host's Jev hears every listener.
13//
14// Time: each page estimates the room's clock from a few ping/pong
15// exchanges (NTP-style: the round trip with the smallest delay, its
16// midpoint). The host sends its start in the room's clock; a guest turns
17// it back into its own. Both define "started" the same way, at the moment
18// their scheduler starts, so the scheduler's own fixed latencies cancel.
19//
20// The page calls in at three places (useReplContext): partyBeforeStart and
21// partyStarted around each scheduler start, and partyToggled when it stops.
22import { followDecisions, setReactions, watchDecisions } from './jevCore.mjs';
23import { nowPlaying, playSong, readSongs, showSong, songPath } from './client.mjs';
24import { setOwnCode } from './sandbox.mjs';
25
26export const PARTY_PATH = '/jev/party/';
27// Live parties listed to join (the listening tab): GET gives them, and
28// GET <entry id> the room to join, from a room that is listed.
29export const PARTIES_PATH = '/jev/parties';
30const PARTY_PARAM = 'party';
31const HOST_KEY = (room) => `jevstrudel.party.${room}`;
32const PINGS = 6; // at joining; the best of them sets the offset
33const REPING_MS = 60_000;
34const ROOM = /^[A-Za-z0-9_-]{16,43}$/;
35
36// 128 random bits, base64url: the room id is the party's only secret from
37// people it was not shared with.
38export function randomId(bytes = 16) {
39  const a = crypto.getRandomValues(new Uint8Array(bytes));
40  return btoa(String.fromCharCode(...a))
41    .replace(/\+/g, '-')
42    .replace(/\//g, '_')
43    .replace(/=+$/, '');
44}
45
46// A short hash of a song's code, so a guest can tell the host edited it.
47export function codeHash(code = '') {
48  let h = 0x811c9dc5;
49  for (let i = 0; i < code.length; i++) h = Math.imul(h ^ code.charCodeAt(i), 0x01000193);
50  return (h >>> 0).toString(16).padStart(8, '0');
51}
52
53// The room's clock from ping/pong samples: `c` sent and `r` received in the
54// page's clock, `s` the room's reading. The best sample is the fastest round
55// trip; the room's reading is taken to be at its midpoint.
56export function clockOffset(samples) {
57  const best = [...samples].sort((a, b) => a.r - a.c - (b.r - b.c))[0];
58  return best ? { offset: best.s - (best.c + best.r) / 2, rtt: best.r - best.c } : null;
59}
60
61// The cycle a guest starts at: where the host is now, at the host's cps.
62// Negative when the host starts in the future (a guest faster to start).
63export function cycleNow(clock, roomNow) {
64  return ((roomNow - clock.startedAt) / 1000) * clock.cps;
65}
66
67// ── state, for the UI ─────────────────────────────
68let state = {
69  room: null,
70  role: null, // 'host' | 'guest'
71  status: 'off', // 'connecting' | 'open' | 'reconnecting' | 'ended' | 'off'
72  people: 0,
73  hostHere: false,
74  song: null, // the performance's song id
75  note: null, // anything the listener should know (edited song, joined mid-song, …)
76  rtt: null,
77  joinedAt: null, // the cycle a guest's scheduler started at
78  waiting: false, // a guest waiting for the host to press play
79};
80const listeners = new Set();
81function set(patch) {
82  state = { ...state, ...patch };
83  for (const l of listeners) l();
84}
85export const partyStore = {
86  subscribe(l) {
87    listeners.add(l);
88    return () => listeners.delete(l);
89  },
90  get: () => state,
91};
92
93// ── the connection ────────────────────────────────
94let editorRef = null;
95let socket = null;
96let key = null;
97let retry = null;
98let retryMs = 1000;
99let pinger = null;
100let samples = [];
101let offset = 0; // the room's clock minus this page's
102const roomNow = () => Date.now() + offset;
103
104// the current performance, as the room has it
105let run = null; // { run, song, code }
106let clock = null; // { startedAt, cps }
107let clockWaiters = [];
108const decisions = new Map(); // `${view}:${segment}` → { view, segment, d }
109const reactions = new Map(); // segment → { fire, sleep }
110
111// guest: the playing song's receivers, and whether the listener is in
112// (played, and has not stopped since)
113let receivers = null;
114let listening = false;
115let ourStop = false; // a stop this module made, not the listener
116
117// host: the decisions watcher, this page's last start, and messages that
118// wait for the room to number the run
119let watching = null;
120let started = null; // { at, cps } in this page's clock
121let queue = null;
122
123function send(message) {
124  if (socket?.readyState === 1) socket.send(JSON.stringify(message));
125}
126
127export function linkFor(room, songId) {
128  return `${location.origin}${songPath({ id: songId })}?${PARTY_PARAM}=${room}`;
129}
130
131function ping() {
132  samples = [];
133  for (let i = 0; i < PINGS; i++) setTimeout(() => send({ t: 'ping', c: Date.now() }), i * 150);
134}
135
136// Whether a party this page hosts is listed for anyone to join: the
137// lobby's "show me here" (lobby.mjs sets it, which imports this module).
138// Sent as the host connects, and again the moment it changes
139// (`listedChanged`), so the party leaves or rejoins the live list at once;
140// its guests stay in either way.
141let listed = () => false;
142export const setListedCheck = (fn) => void (listed = fn);
143export function listedChanged() {
144  if (state.role !== 'host') return;
145  const on = listed();
146  // the room checks the name is its own before keeping it (party-room.ts)
147  send({ t: 'listed', listed: on, ...(on ? { room: state.room } : {}) });
148}
149
150function connect() {
151  clearTimeout(retry);
152  const { room, role } = state;
153  const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
154  const params = new URLSearchParams({ role, ...(role === 'host' ? { key, listed: listed() ? '1' : '0' } : {}) });
155  const ws = new WebSocket(`${protocol}//${location.host}${PARTY_PATH}${room}?${params}`);
156  socket = ws;
157  ws.onopen = () => {
158    retryMs = 1000;
159    ping();
160    clearInterval(pinger);
161    pinger = setInterval(ping, REPING_MS);
162  };
163  ws.onmessage = (e) => {
164    let m;
165    try {
166      m = JSON.parse(e.data);
167    } catch {
168      return;
169    }
170    if (socket === ws) onMessage(m);
171  };
172  ws.onclose = (e) => {
173    if (socket !== ws) return;
174    clearInterval(pinger);
175    if (e.code >= 4400 && e.code < 4600) {
176      set({ status: 'ended', note: e.reason || 'this party has ended' });
177      socket = null;
178      return;
179    }
180    if (state.status === 'off') return;
181    set({ status: 'reconnecting' });
182    retry = setTimeout(connect, retryMs);
183    retryMs = Math.min(retryMs * 2, 10_000);
184  };
185}
186
187function resetPerformance(next) {
188  run = next;
189  clock = null;
190  decisions.clear();
191  reactions.clear();
192  receivers = null;
193}
194
195function onMessage(m) {
196  switch (m.t) {
197    case 'pong': {
198      samples.push({ c: m.c, s: m.s, r: Date.now() });
199      const best = clockOffset(samples);
200      if (best) {
201        offset = best.offset;
202        set({ rtt: Math.round(best.rtt) });
203      }
204      return;
205    }
206    case 'welcome': {
207      set({ status: 'open', people: m.people, hostHere: m.host });
208      // the same performance as before a reconnect: carry on with it
209      const same = Boolean(run && m.run && run.run === m.run.run);
210      if (state.role === 'host') {
211        if (!started) return;
212        // a party started while a song plays joins it now; a reconnect
213        // re-sends what the room may have missed (it keeps one of each)
214        if (same) share();
215        else announce();
216        return;
217      }
218      if (!same) resetPerformance(m.run);
219      for (const x of m.decisions ?? []) {
220        decisions.set(`${x.view}:${x.segment}`, x);
221        if (same) receivers?.[x.view]?.(x.segment, x.d);
222      }
223      for (const [segment, counts] of Object.entries(m.reactions ?? {})) {
224        reactions.set(Number(segment), counts);
225        if (same) setReactions(Number(segment), counts);
226      }
227      if (m.clock) gotClock(m.clock);
228      set({ song: m.run?.song ?? null });
229      const joining = playOnJoin && Boolean(m.run);
230      if (joining) takePlayOnJoin();
231      followSong({ restart: joining || (!same && listening) });
232      return;
233    }
234    case 'people':
235      set({ people: m.n, hostHere: m.host });
236      return;
237    case 'run':
238      if (state.role === 'host') {
239        // the room numbered our run: what waited for it goes now
240        run = { run: m.run, song: m.song, code: m.code };
241        const waiting = queue ?? [];
242        queue = null;
243        for (const message of waiting) send({ ...message, run: m.run });
244        return;
245      }
246      resetPerformance({ run: m.run, song: m.song, code: m.code });
247      set({ song: m.song, joinedAt: null });
248      takePlayOnJoin();
249      followSong({ restart: true });
250      return;
251    case 'clock':
252      if (state.role === 'guest' && run && m.run === run.run) gotClock(m);
253      return;
254    case 'decision': {
255      if (state.role !== 'guest' || !run || m.run !== run.run) return;
256      decisions.set(`${m.view}:${m.segment}`, m);
257      receivers?.[m.view]?.(m.segment, m.d);
258      return;
259    }
260    case 'reactions':
261      reactions.set(m.segment, { fire: m.fire, sleep: m.sleep });
262      setReactions(m.segment, m);
263      return;
264    case 'end':
265      if (state.role === 'guest' && run && m.run === run.run) {
266        clock = null;
267        stopForHost();
268        set({ waiting: listening, joinedAt: null });
269      }
270      return;
271    case 'refused':
272    case 'error':
273      set({ note: m.why });
274      return;
275  }
276}
277
278function gotClock(c) {
279  clock = { startedAt: c.startedAt, cps: c.cps };
280  releaseClockWaiters();
281}
282
283// A guest's start waiting for the host's clock (partyStarted) ends when the
284// clock arrives, and also when the guest leaves or stops: evaluations run
285// one at a time (packages/core/repl.mjs), so a start left waiting would hold
286// every later one. Without a clock partyStarted returns and sets nothing.
287function releaseClockWaiters() {
288  const waiting = clockWaiters;
289  clockWaiters = [];
290  for (const resolve of waiting) resolve();
291}
292
293// ── joining and leaving ───────────────────────────
294function begin(room, role) {
295  resetPerformance(null);
296  set({ room, role, status: 'connecting', people: 0, hostHere: false, note: null, joinedAt: null, waiting: false });
297  connect();
298}
299
300function urlWithParty(room) {
301  try {
302    const url = new URL(location.href);
303    if (room) url.searchParams.set(PARTY_PARAM, room);
304    else url.searchParams.delete(PARTY_PARAM);
305    history.replaceState(history.state, '', url);
306  } catch {
307    // a sandboxed page: the party works, the address bar does not show it
308  }
309}
310
311// The page opened a party link (?party=<room>): join as a guest, or as the
312// host when this tab started that party (its key is in sessionStorage).
313export function initParty(ref) {
314  editorRef = ref;
315  if (typeof location === 'undefined') return;
316  const room = new URLSearchParams(location.search).get(PARTY_PARAM);
317  if (!room || !ROOM.test(room)) return;
318  try {
319    key = sessionStorage.getItem(HOST_KEY(room));
320  } catch {
321    key = null;
322  }
323  begin(room, key ? 'host' : 'guest');
324}
325
326// Listening along from the lobby (lobby.mjs): join `room`, which the tab
327// listened to started for us, as a guest, and play the host's song as soon
328// as the room says which it is. The click on "listen along" is the gesture
329// that lets it play.
330let playOnJoin = false;
331export function joinParty(room) {
332  if (!ROOM.test(room)) return;
333  if (state.status !== 'off' && state.status !== 'ended') leaveParty();
334  key = null;
335  playOnJoin = true;
336  urlWithParty(room);
337  begin(room, 'guest');
338}
339// A listener who joined from the lobby is in once the song is known.
340function takePlayOnJoin() {
341  if (!playOnJoin) return;
342  playOnJoin = false;
343  listening = true;
344}
345
346// "listen together": start a party on the song that is playing (or about to).
347export function startParty() {
348  if (state.status !== 'off' && state.status !== 'ended') return state.room;
349  const room = randomId();
350  key = randomId(24);
351  try {
352    sessionStorage.setItem(HOST_KEY(room), key);
353  } catch {
354    // a host that cannot keep its key cannot reclaim the party on reload
355  }
356  urlWithParty(room);
357  begin(room, 'host');
358  return room;
359}
360
361export function leaveParty() {
362  const { room, role } = state;
363  set({ status: 'off' });
364  clearTimeout(retry);
365  clearInterval(pinger);
366  socket?.close(1000, 'left');
367  socket = null;
368  watching?.stop();
369  watching = null;
370  if (role === 'host') {
371    try {
372      sessionStorage.removeItem(HOST_KEY(room));
373    } catch {}
374  }
375  if (role === 'guest' && listening) stopForHost();
376  listening = false;
377  playOnJoin = false;
378  resetPerformance(null);
379  urlWithParty(null);
380  set({ room: null, role: null, people: 0, song: null, note: null, joinedAt: null, waiting: false });
381  releaseClockWaiters();
382}
383
384// A reaction: through the room when in a party (it counts everyone's and
385// tells everyone, the host's Jev included), else straight to the booth.
386export function react(booth, segment, kind) {
387  if (state.status === 'open' && run) send({ t: 'react', segment, kind });
388  else booth.react(segment, kind);
389}
390
391// ── host ──────────────────────────────────────────
392const songOf = () => readSongs().find((s) => s.id === nowPlaying.get());
393
394// Announce the performance that is starting (or playing): the song, then
395// every decision as its jev()s settle it, and the clock once it starts.
396function announce() {
397  const song = songOf();
398  if (!song) {
399    set({ note: 'only a song from the list can be shared: pick one, and the party plays it' });
400    return;
401  }
402  const code = editorRef?.current?.code ?? '';
403  queue = [];
404  send({ t: 'run', song: song.id, code: codeHash(code) });
405  set({ song: song.id, note: code === song.code ? null : 'you edited this song: guests hear it as published' });
406  share();
407}
408// Watch the performance's decisions, and send those already settled and
409// the clock (again, after a reconnect: the room keeps one of each).
410function share() {
411  watching?.stop();
412  watching = watchDecisions((view, segment, d) => sendRun({ t: 'decision', view, segment, d }));
413  for (const x of watching.settled) sendRun({ t: 'decision', view: x.view, segment: x.segment, d: x.decision });
414  if (started) sendRun({ t: 'clock', startedAt: started.at + offset, cps: started.cps });
415}
416// a message that belongs to the current run, which the room may not have
417// numbered yet
418function sendRun(message) {
419  if (queue) queue.push(message);
420  else if (run) send({ ...message, run: run.run });
421}
422
423// ── the scheduler's hooks (useReplContext) ─────────
424// Before the song's opening is asked about: the host announces the new
425// performance and watches its decisions; a guest's jev()s start following.
426export function partyBeforeStart() {
427  started = null;
428  if (state.status === 'off' || state.status === 'ended') return;
429  if (state.role === 'host') {
430    if (state.status === 'open') announce();
431    return;
432  }
433  listening = true;
434  receivers = followDecisions();
435  for (const { view, segment, d } of decisions.values()) receivers[view]?.(segment, d);
436  for (const [segment, counts] of reactions) setReactions(segment, counts);
437}
438
439// The last step before the scheduler's first tick. The host notes its
440// start and tells the room; a guest waits for the host's start and begins
441// at the cycle the host is on now.
442export async function partyStarted(scheduler) {
443  if (state.role === 'guest' && state.status !== 'off' && state.status !== 'ended') {
444    if (!clock) {
445      set({ waiting: true });
446      await new Promise((resolve) => clockWaiters.push(resolve));
447      set({ waiting: false });
448    }
449    if (!clock) return; // left meanwhile
450    if (scheduler.cps !== clock.cps) scheduler.setCps(clock.cps);
451    let cycle = cycleNow(clock, roomNow());
452    if (cycle < 0) {
453      await new Promise((r) => setTimeout(r, (-cycle / clock.cps) * 1000));
454      cycle = cycleNow(clock, roomNow());
455    }
456    // the cyclist's first tick begins at lastEnd (packages/core/cyclist.mjs)
457    scheduler.lastEnd = Math.max(0, cycle);
458    set({ joinedAt: scheduler.lastEnd });
459    if (songOf()?.id !== run?.song) setTimeout(() => followSong({ restart: true }), 0);
460    return;
461  }
462  started = { at: Date.now(), cps: scheduler.cps };
463  if (state.role === 'host' && state.status === 'open') {
464    sendRun({ t: 'clock', startedAt: started.at + offset, cps: started.cps });
465  }
466}
467
468export function partyToggled(playing) {
469  if (playing) return;
470  releaseClockWaiters();
471  started = null;
472  if (state.role === 'host') {
473    watching?.stop();
474    watching = null;
475    sendRun({ t: 'end' });
476    return;
477  }
478  if (state.role === 'guest') {
479    // the listener's own stop: they are out until they press play again
480    if (ourStop) ourStop = false;
481    else listening = false;
482  }
483}
484
485// ── guest ─────────────────────────────────────────
486function stopForHost() {
487  const editor = editorRef?.current;
488  if (!editor?.repl?.scheduler?.started) return;
489  ourStop = true; // partyToggled consumes it
490  editor.stop();
491}
492
493// Load the performance's song into the editor (the host may have moved on
494// to another), and, for a listener who is in, play it: it starts in sync
495// once the host's clock arrives.
496function followSong({ restart = false } = {}) {
497  const editor = editorRef?.current;
498  const song = readSongs().find((s) => s.id === run?.song);
499  if (!editor || !song) return;
500  if (song.code && codeHash(song.code) !== run.code) {
501    set({ note: 'the host edited this song: you hear it as published, with the host’s Jev' });
502  }
503  const differs = nowPlaying.get() !== song.id || editor.code !== song.code;
504  if (!listening) {
505    if (differs) {
506      setOwnCode(editor, song.code);
507      nowPlaying.set(song.id);
508      showSong(song);
509    }
510    return;
511  }
512  if (differs || restart) {
513    // playSong stops first; that stop is ours (partyToggled consumes it)
514    ourStop = Boolean(editor.repl?.scheduler?.started);
515    playSong(editor, song);
516  }
517}
518
519export const isGuest = () => state.role === 'guest' && state.status !== 'off';