1// Jev picking songs and judging code, beside the booth: 2// 3// - MoodPicker (welcome tab): the listener says how they feel; Jev picks 4// the song and it plays. 5// - NextTrack and AutoplayToggle (beside play, Panel.jsx), RadioToggle 6// (the same setting on the welcome tab) and RadioDriver (always mounted, 7// in the editor): ⏭ has Jev pick another song now, and with autoplay on, 8// a song ending does the same. Both go through nextTrack.mjs: signed in 9// (account.mjs), what they play is kept with the account 10// (radioHistory.mjs), and the songs played most recently, on any device, 11// are not offered again; signed out, the history lasts the tab. 12// - ArtCheck (header): "is it art?" asks the art critic about whatever is 13// in the editor, beside the score the song it came from got. 14import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; 15import { budgetText } from './budget.mjs'; 16import { account } from './account.mjs'; 17import { nowPlaying, playSong, readSongs } from './client.mjs'; 18import { radioHistory, recordRadioPlay } from './radioHistory.mjs'; 19import { isGuest, partyStore } from './party.mjs'; 20import { endCycle, nextTracker } from './nextTrack.mjs'; 21import { jevLog } from './jevLog.mjs'; 22import { useBooth } from './JevBooth.jsx'; 23import { judgeArt, pickByMood, pickNext } from './picker.mjs'; 24import { ArtBreakdown } from './ArtBreakdown.jsx'; 25 26import { themeColor as ACCENT } from './site.mjs'; 27const pct = (p) => (typeof p === 'number' ? `${Math.round(p * 100)}%` : ''); 28 29// ── autoplay and the next track ─────────────────── 30// Autoplay on or off is remembered per browser (it was "radio", and keeps 31// that key so a listener's choice survives); the history lasts the tab, or 32// the account when signed in (nextTrack.mjs). 33const AUTOPLAY = 'jevstrudel.radio'; 34let autoplayOn = (() => { 35 try { 36 return localStorage.getItem(AUTOPLAY) === 'on'; 37 } catch { 38 return false; 39 } 40})(); 41const autoplayListeners = new Set(); 42export const autoplay = { 43 subscribe(listener) { 44 autoplayListeners.add(listener); 45 return () => autoplayListeners.delete(listener); 46 }, 47 get: () => autoplayOn, 48 set(on) { 49 autoplayOn = on; 50 try { 51 localStorage.setItem(AUTOPLAY, on ? 'on' : 'off'); 52 } catch { 53 // the toggle still works for this tab 54 } 55 for (const listener of autoplayListeners) listener(); 56 }, 57}; 58const useAutoplay = () => useSyncExternalStore(autoplay.subscribe, autoplay.get, () => false); 59 60const tracker = nextTracker({ pick: pickNext, play: playSong, loadRecent: radioHistory, record: recordRadioPlay }); 61 62// The site song playing as written, or null (an edited song, a listener's, 63// the listener's own code: Jev then picks from history alone). 64function playingAsWritten(editor, songs) { 65 const song = songs.find((s) => s.id === nowPlaying.get()); 66 return song && editor?.code === song.code ? song : null; 67} 68 69// Jev picks the next track and it plays. `why` says what asked for it. 70async function playNext(editor, why) { 71 const songs = readSongs(); 72 if (!editor || !songs.length) return; 73 const r = await tracker.next({ songs, current: playingAsWritten(editor, songs), user: account.get().user, editor }); 74 if (!r) return; 75 // into Jev's request log, in the pane under the editor (jevLog.mjs), with 76 // the budget as the relay last reported it (JevBudget.jsx) 77 const budget = budgetText(account.get()).text; 78 jevLog.add( 79 r.error 80 ? { status: 'error', text: `${why} Jev unreachable (${r.error}); playing ${r.song.title}` } 81 : { status: 'picked', text: `${why} Jev picked ${r.song.title}${r.p !== undefined ? ` · ${pct(r.p)}` : ''} · ${budget}` }, 82 ); 83} 84 85// ⏭ beside play: Jev picks another song now. A party's guest hears what 86// the host plays, so it is off for them. 87export function NextTrack({ context, isEmbedded = false }) { 88 const [asking, setAsking] = useState(false); 89 const guest = useSyncExternalStore(partyStore.subscribe, isGuest, () => false); 90 const press = async () => { 91 setAsking(true); 92 try { 93 await playNext(context.editorRef.current, '⏭'); 94 } finally { 95 setAsking(false); 96 } 97 }; 98 return ( 99 <button 100 onClick={press} 101 disabled={asking || guest} 102 data-testid="jev-next-track" 103 title={guest ? 'the party’s host picks the songs' : 'next track: Jev picks another song from what you have played'} 104 className="flex items-center space-x-1 px-2 hover:opacity-50 disabled:opacity-40" 105 > 106 <span aria-hidden>⏭</span> 107 {!isEmbedded && <span className="hidden xl:inline">{asking ? '…' : 'next'}</span>} 108 </button> 109 ); 110} 111 112// Beside ⏭: whether a song ending plays the next by itself. 113export function AutoplayToggle({ isEmbedded = false }) { 114 const on = useAutoplay(); 115 return ( 116 <label 117 className="flex items-center space-x-1 px-2 cursor-pointer hover:opacity-70 whitespace-nowrap" 118 title="autoplay: when a song ends, Jev picks the next one from what you have played" 119 > 120 <input type="checkbox" checked={on} onChange={(e) => autoplay.set(e.target.checked)} data-testid="jev-autoplay" /> 121 {!isEmbedded && ( 122 <span> 123 auto<span className="hidden xl:inline">play</span> 124 </span> 125 )} 126 </label> 127 ); 128} 129 130// The same setting on the jev panel, with its longer label. 131export function RadioToggle() { 132 const on = useAutoplay(); 133 return ( 134 <label className="not-prose flex items-center gap-2 my-2 cursor-pointer select-none"> 135 <input type="checkbox" checked={on} onChange={(e) => autoplay.set(e.target.checked)} /> 136 <span>📻 Autoplay: when a song ends, Jev picks the next one (also beside play, with ⏭ next)</span> 137 </label> 138 ); 139} 140 141export function RadioDriver({ context }) { 142 const on = useAutoplay(); 143 const booth = useBooth(); 144 const boothRef = useRef(booth); 145 boothRef.current = booth; 146 147 useEffect(() => { 148 if (!on || !context.started) return; 149 const timer = setInterval(() => { 150 const editor = context.editorRef.current; 151 const songs = readSongs(); 152 // only a song as written: an edited one is the listener's to stop; and 153 // never a party's guest, who hears what the host's autoplay picks 154 const song = playingAsWritten(editor, songs); 155 if (isGuest() || !song) return; 156 const end = endCycle(song, boothRef.current); 157 if (end === null || editor.repl.scheduler.now() < end) return; 158 playNext(editor, '📻'); 159 }, 500); 160 return () => clearInterval(timer); 161 }, [on, context.started, context.editorRef]); 162 163 return null; 164} 165 166// ── mood ────────────────────────────────────────── 167export function MoodPicker({ context }) { 168 const [mood, setMood] = useState(''); 169 const [state, setState] = useState(null); // { asking } | { picked, p } | { error } 170 const ask = async (e) => { 171 e.preventDefault(); 172 if (!mood.trim() || state?.asking) return; 173 setState({ asking: true }); 174 try { 175 const { song, p } = await pickByMood(readSongs(), mood.trim()); 176 setState({ picked: song.title, p }); 177 await playSong(context.editorRef.current, song); 178 } catch (err) { 179 setState({ error: err.message }); 180 } 181 }; 182 return ( 183 <form className="not-prose flex flex-wrap items-center gap-2 my-2" onSubmit={ask}> 184 <input 185 className="grow min-w-0 bg-background border border-muted rounded px-2 py-1" 186 maxLength={280} 187 placeholder="How do you feel? Jev picks the song." 188 value={mood} 189 onChange={(e) => setMood(e.target.value)} 190 /> 191 <button 192 type="submit" 193 className="border rounded px-2 py-1 hover:opacity-70 disabled:opacity-50" 194 style={{ borderColor: ACCENT }} 195 disabled={!mood.trim() || state?.asking} 196 > 197 {state?.asking ? 'asking Jev…' : 'ask Jev'} 198 </button> 199 {state?.picked && ( 200 <span className="w-full text-xs opacity-80"> 201 Jev picked <b>{state.picked}</b> 202 {state.p !== undefined && <> ({pct(state.p)})</>} 203 </span> 204 )} 205 {state?.error && <span className="w-full text-xs text-red-400">Jev could not pick: {state.error}</span>} 206 </form> 207 ); 208} 209 210// ── is it art? ──────────────────────────────────── 211const titleOf = (code) => code.match(/^\/\/ @title (.+)$/m)?.[1]?.trim(); 212 213export function ArtCheck({ context }) { 214 const [state, setState] = useState(null); // { asking } | { art, song, same } | { error } 215 useEffect(() => { 216 if (!state || state.asking) return; 217 const t = setTimeout(() => setState(null), 12000); 218 return () => clearTimeout(t); 219 }, [state]); 220 221 const ask = async () => { 222 const editor = context.editorRef.current; 223 if (!editor || state?.asking) return; 224 const code = editor.code ?? ''; 225 const song = readSongs().find((s) => s.id === nowPlaying.get()); 226 setState({ asking: true }); 227 try { 228 const { art, criteria } = await judgeArt({ 229 title: titleOf(code) ?? song?.title ?? 'untitled', 230 spec: song?.spec ?? '', 231 code, 232 // the measured mix is the song's as written, so only for unedited code 233 measured: song?.code === code ? song.measured : null, 234 }); 235 setState({ art, criteria, song, same: song?.code === code }); 236 } catch (err) { 237 setState({ error: err.message }); 238 } 239 }; 240 241 return ( 242 <span className="relative"> 243 <button 244 className="text-sm font-mono whitespace-nowrap hover:opacity-70" 245 style={{ color: ACCENT }} 246 onClick={ask} 247 title="ask Jev, the art critic, whether the code in the editor is art" 248 > 249 🎨 {state?.asking ? 'judging…' : 'is it art?'} 250 </button> 251 {state && !state.asking && ( 252 <span 253 className="absolute left-0 top-full mt-2 z-50 w-80 bg-background text-foreground border rounded px-3 py-2 shadow-lg text-sm font-mono whitespace-normal" 254 style={{ borderColor: state.error ? '#f87171' : ACCENT }} 255 onClick={() => setState(null)} 256 > 257 {state.error ? ( 258 <>Jev could not judge it: {state.error}</> 259 ) : ( 260 <> 261 Jev says this is <b>{pct(state.art)} art</b>. 262 <span className="block mt-2 text-xs"> 263 <ArtBreakdown criteria={state.criteria} compact /> 264 </span> 265 {state.song?.art && ( 266 <span className="block text-xs opacity-80 mt-1"> 267 {state.same 268 ? `The song as written scored ${pct(state.song.art.p)} when it was recorded.` 269 : `${state.song.title} as written: ${pct(state.song.art.p)}. Yours is edited.`} 270 </span> 271 )} 272 </> 273 )} 274 </span> 275 )} 276 </span> 277 ); 278}