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}