1// Every listener's 🔥/😴, across plays (worker/src/listening.ts). The 2// booth's buttons tell Jev how a section lands within one play (jevCore's 3// `audience`); each press is also sent here, anonymously, and counted per 4// (song, section, reaction). The tally comes back: 5// 6// - on the song's page (the jev panel's song view), per section; 7// - to Jev, as `listenersSoFar` in each request (jev.mjs hands jevCore 8// `tallyFor(nowPlaying)`; jevCore cuts it to the sections that matter). 9// 10// The playing song's tally is fetched whenever the song changes, and a 11// press is added to it at once, so the page need not ask again. 12import { nowPlaying } from './client.mjs'; 13 14export const REACTIONS = '/jev/reactions'; 15 16// The name a reaction goes under: the form's section in `segment` (walk()), 17// or, for a song without a form, the section's number from 1. 18export function sectionOf(booth, segment) { 19 const name = booth?.form?.name; 20 const choice = name ? booth.decisions[segment]?.values[name]?.choice : null; 21 return choice ?? (name ? null : String(segment + 1)); 22} 23 24// song id → { [section]: { fire, sleep } } 25const tallies = new Map(); 26const loading = new Set(); 27const listeners = new Set(); 28let version = 0; 29const changed = () => { 30 version++; 31 for (const listener of listeners) listener(); 32}; 33 34export const tallyStore = { 35 subscribe(listener) { 36 listeners.add(listener); 37 return () => listeners.delete(listener); 38 }, 39 // changes whenever any song's tally does 40 version: () => version, 41}; 42 43export const tallyFor = (song) => (song ? (tallies.get(song) ?? null) : null); 44 45export async function loadTally(song, fetchImpl = globalThis.fetch) { 46 if (!song || loading.has(song)) return; 47 loading.add(song); 48 try { 49 const res = await fetchImpl(`${REACTIONS}?song=${encodeURIComponent(song)}`); 50 if (!res.ok) return; 51 const { sections } = await res.json(); 52 if (!Array.isArray(sections)) return; 53 tallies.set( 54 song, 55 Object.fromEntries(sections.map(({ section, fire, sleep }) => [String(section), { fire: fire | 0, sleep: sleep | 0 }])), 56 ); 57 changed(); 58 } catch { 59 // no tally: the page shows none and Jev hears none 60 } finally { 61 loading.delete(song); 62 } 63} 64 65// Counts one reaction on the site, fire and forget, and in this page's 66// copy of the tally straight away. 67export function sendReaction({ song, section, reaction }, fetchImpl = globalThis.fetch) { 68 if (!song || !section) return; 69 const tally = tallies.get(song) ?? {}; 70 const row = (tally[section] ??= { fire: 0, sleep: 0 }); 71 row[reaction] += 1; 72 tallies.set(song, tally); 73 changed(); 74 try { 75 fetchImpl?.(REACTIONS, { 76 method: 'POST', 77 headers: { 'Content-Type': 'application/json' }, 78 body: JSON.stringify({ song, section, reaction }), 79 keepalive: true, 80 })?.catch?.(() => {}); 81 } catch { 82 // not counted 83 } 84} 85 86// In the page: the playing song's tally, fetched as each song is loaded. 87if (typeof window !== 'undefined') { 88 nowPlaying.subscribe(() => loadTally(nowPlaying.get())); 89 loadTally(nowPlaying.get()); 90}