jevstrudel.git / website / src / jev / Lobby.jsx
Lobby.jsx160 lines · 6.3 KB · raw
1// Who is listening now (lobby.mjs): the jev panel's list of everyone's tabs,
2// what each plays and how far through it is, with "listen along"; and the
3// header's 👥 count, which opens that list. Everything shown of another tab
4// is other people's data, rendered as text only.
5import { useEffect, useState, useSyncExternalStore } from 'react';
6import { NameLink } from './NameLink.jsx';
7import { playSong, readSongs } from './client.mjs';
8import { groupTabs, listenAlong, lobbyStore, progressOf, cycleNow, roomNow, setVisible } from './lobby.mjs';
9import { themeColor as ACCENT } from './site.mjs';
10
11export const useLobby = () => useSyncExternalStore(lobbyStore.subscribe, lobbyStore.get, lobbyStore.get);
12
13// Re-render once a second while anything plays, so progress moves.
14function useSecond(on) {
15  const [, setT] = useState(0);
16  useEffect(() => {
17    if (!on) return;
18    const timer = setInterval(() => setT((t) => t + 1), 1000);
19    return () => clearInterval(timer);
20  }, [on]);
21}
22
23const listening = (n) => `${n} ${n === 1 ? 'tab' : 'tabs'} open`;
24
25
26function what(tab, songs) {
27  if (tab.song) {
28    const song = songs.find((s) => s.id === tab.song);
29    return { title: song?.title ?? tab.song, song, note: tab.edited ? 'edited' : null };
30  }
31  if (tab.listener) return { title: tab.title ?? 'a listener’s song', note: 'listener song' };
32  return { title: 'their own code', note: null };
33}
34
35function Progress({ tab }) {
36  const p = progressOf(tab, roomNow());
37  const c = cycleNow(tab, roomNow());
38  if (p !== null) {
39    return (
40      <div className="flex items-center gap-2 text-xs opacity-80">
41        <div className="grow h-1 rounded bg-muted overflow-hidden" style={{ minWidth: '4rem' }}>
42          <div className="h-full" style={{ width: `${Math.round(p * 100)}%`, background: ACCENT }} />
43        </div>
44        <span>{Math.round(p * 100)}%</span>
45      </div>
46    );
47  }
48  if (c === null) return <div className="text-xs opacity-60">stopped</div>;
49  return (
50    <div className="text-xs opacity-80">
51      bar {Math.floor(c) + 1}
52      {tab.max ? ` of at most ${tab.max}` : ''}
53      {tab.section ? ` · ${tab.section}` : ''}
54    </div>
55  );
56}
57
58function TabRow({ tab, songs, context, pending }) {
59  const { title, song, note } = what(tab, songs);
60  const canListen = Boolean(tab.song && tab.playing && song?.code);
61  return (
62    <li className="flex flex-wrap items-center gap-x-3 gap-y-1 py-1">
63      <span className="grow min-w-0">
64        <span>{tab.playing ? '▶' : '⏸'} </span>
65        <b className="break-words">{title}</b>
66        {note && <span className="opacity-60"> · {note}</span>}
67        {tab.section && tab.length && <span className="opacity-60"> · {tab.section}</span>}
68        <Progress tab={tab} />
69      </span>
70      {canListen && (
71        <button
72          className="rounded border px-2 py-0.5 hover:opacity-80 disabled:opacity-50"
73          style={{ borderColor: ACCENT }}
74          disabled={Boolean(pending)}
75          onClick={() => listenAlong(tab)}
76          title="hear what they hear, in sync, with their Jev: a listening party on their song"
77        >
78          {pending === tab.id ? 'asking…' : '🎧 listen along'}
79        </button>
80      )}
81      {!canListen && song?.code && (
82        <button
83          className="rounded border border-muted px-2 py-0.5 hover:opacity-80"
84          onClick={() => playSong(context.editorRef.current, song)}
85          title="play this song yourself, from the top"
86        >
87          play it
88        </button>
89      )}
90      {tab.listener && (
91        <a
92          className="rounded border border-muted px-2 py-0.5 hover:opacity-80"
93          href={`/?listener=${encodeURIComponent(tab.listener)}`}
94          title="open this listener’s song, to play from the top in its sandbox"
95        >
96          open it
97        </a>
98      )}
99    </li>
100  );
101}
102
103// `forSong` narrows it to the tabs on one song (a song's page,
104// SongListening.jsx): `{ song }` for a site song's id, `{ listener }` for a
105// listener song's.
106const onSong = (forSong) => (tab) =>
107  forSong.listener ? tab.listener === forSong.listener : tab.song === forSong.song;
108
109export function WhoIsListening({ context, forSong = null }) {
110  const lobby = useLobby();
111  const songs = readSongs();
112  const tabs = forSong ? lobby.tabs.filter(onSong(forSong)) : lobby.tabs;
113  useSecond(tabs.some((t) => t.playing));
114  if (lobby.status === 'off') return null;
115  const groups = groupTabs(tabs);
116  const hidden = Math.max(0, lobby.n - 1 - lobby.tabs.length);
117  return (
118    <div id={forSong ? undefined : 'jev-lobby'} className="not-prose my-3 text-sm rounded border px-3 py-2" style={{ borderColor: ACCENT }}>
119      <div className="flex flex-wrap items-center gap-2">
120        <b>{forSong ? '👥 on this song now' : '👥 listening now'}</b>
121        <span className="opacity-70">
122          {lobby.status !== 'open'
123            ? lobby.status === 'connecting'
124              ? 'joining…'
125              : 'reconnecting…'
126            : forSong
127              ? `${tabs.length} ${tabs.length === 1 ? 'tab' : 'tabs'} of ${listening(lobby.n)}`
128              : listening(lobby.n)}
129          {!forSong && hidden > 0 && ` · ${hidden} not shown`}
130        </span>
131        <label
132          className="ml-auto flex items-center gap-1 cursor-pointer select-none"
133          title="others see what your tab plays and can listen along; your name shows when you are signed in"
134        >
135          <input type="checkbox" checked={lobby.visible} onChange={(e) => setVisible(e.target.checked)} data-testid="jev-lobby-visible" />
136          <span>show me here</span>
137        </label>
138      </div>
139      {lobby.error && <div className="text-xs text-red-400 mt-1">could not listen along: {lobby.error}</div>}
140      {groups.length === 0 ? (
141        <div className="opacity-60 mt-1">{forSong ? 'nobody else has this song open right now' : 'nobody else here right now'}</div>
142      ) : (
143        <ul className="mt-1 space-y-1">
144          {groups.map((g) => (
145            <li key={g.group}>
146              <div className="opacity-80">
147                <NameLink id={g.account} name={g.name} />
148              </div>
149              <ul className="pl-3 border-l border-muted">
150                {g.tabs.map((tab) => (
151                  <TabRow key={tab.id} tab={tab} songs={songs} context={context} pending={lobby.pending} />
152                ))}
153              </ul>
154            </li>
155          ))}
156        </ul>
157      )}
158    </div>
159  );
160}