1// The listening tab's live parties (parties.mjs): each one's song, how many 2// are in it and whether its host is, with "join" for a listed one. Read 3// again every POLL_MS while shown, and as this page joins or leaves one. 4import { useEffect, useState, useSyncExternalStore } from 'react'; 5import { readSongs } from './client.mjs'; 6import { joinListed, loadParties } from './parties.mjs'; 7import { partyStore } from './party.mjs'; 8import { themeColor as ACCENT } from './site.mjs'; 9 10const POLL_MS = 15_000; 11const since = (ms) => { 12 const min = Math.max(0, Math.round((Date.now() - ms) / 60_000)); 13 return min < 60 ? `${min} min` : `${Math.floor(min / 60)} h ${min % 60} min`; 14}; 15 16// `song` narrows it to one site song's parties (a song's page, 17// SongListening.jsx). 18export function LiveParties({ song = null } = {}) { 19 const party = useSyncExternalStore(partyStore.subscribe, partyStore.get, partyStore.get); 20 const [parties, setParties] = useState(null); 21 const [error, setError] = useState(null); 22 const [joining, setJoining] = useState(null); 23 useEffect(() => { 24 let live = true; 25 const read = () => 26 loadParties().then( 27 (p) => live && (setParties(song ? p.filter((x) => x.song === song) : p), setError(null)), 28 (e) => live && setError(e.message), 29 ); 30 read(); 31 const t = setInterval(read, POLL_MS); 32 return () => { 33 live = false; 34 clearInterval(t); 35 }; 36 // read again as this page joins or leaves a party, which changes the counts 37 }, [song, party.status]); 38 const songs = readSongs(); 39 const title = (id) => (id ? (songs.find((s) => s.id === id)?.title ?? id) : 'between songs'); 40 const inParty = party.status !== 'off' && party.status !== 'ended'; 41 42 const join = async (id) => { 43 setJoining(id); 44 setError(null); 45 try { 46 await joinListed(id); 47 } catch (e) { 48 setError(e.message); 49 } finally { 50 setJoining(null); 51 } 52 }; 53 54 return ( 55 <div className="my-3 text-sm rounded border px-3 py-2" style={{ borderColor: ACCENT }} data-testid="jev-parties"> 56 <b>{song ? '🎉 parties on this song' : '🎉 listening parties'}</b> 57 <span className="opacity-70"> · {parties ? `${parties.length} live` : 'reading…'}</span> 58 {error && <div className="text-xs text-red-400 mt-1">{error}</div>} 59 {parties && parties.length === 0 && ( 60 <div className="opacity-60 mt-1"> 61 {song ? 'No party on this song right now: start one with 👥 listen together.' : "No party right now. Start one from a song's view: 👥 listen together."} 62 </div> 63 )} 64 {parties && parties.length > 0 && ( 65 <ul className="mt-1 space-y-1"> 66 {parties.map((p, i) => ( 67 <li key={p.id ?? `unlisted-${i}`} className="flex flex-wrap items-center gap-x-3"> 68 <span className="grow min-w-0"> 69 <b>{title(p.song)}</b> 70 <span className="opacity-60"> 71 {' '} 72 · {p.people} {p.people === 1 ? 'person' : 'people'} 73 {p.host ? '' : ' · host away'} 74 {p.since ? ` · ${since(p.since)}` : ''} 75 </span> 76 </span> 77 {p.id ? ( 78 <button 79 className="rounded border px-2 py-0.5 hover:opacity-80 disabled:opacity-50" 80 style={{ borderColor: ACCENT }} 81 disabled={Boolean(joining) || inParty || !p.host} 82 title={inParty ? 'you are in a party already' : 'join as a guest: hear what they hear, in sync'} 83 onClick={() => join(p.id)} 84 > 85 {joining === p.id ? 'joining…' : 'join'} 86 </button> 87 ) : ( 88 <span className="text-xs opacity-60" title="its host hides from the listening list"> 89 private 90 </span> 91 )} 92 </li> 93 ))} 94 </ul> 95 )} 96 </div> 97 ); 98}