1import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'; 2import cx from '@src/cx.mjs'; 3import { useSettings } from '@src/settings.mjs'; 4 5import { loadListenerSong as showListenerSong, nowPlaying, playSong, readSongs, songPageId } from '../../../jev/client.mjs'; 6import { SongListening } from '../../../jev/SongListening.jsx'; 7import { MoodPicker, RadioToggle } from '../../../jev/JevPicks.jsx'; 8import { ArtBreakdown } from '../../../jev/ArtBreakdown.jsx'; 9import { JevTakes } from '../../../jev/JevTakes.jsx'; 10import { NameLink } from '../../../jev/NameLink.jsx'; 11import { AgreeWithJev } from '../../../jev/AgreeWithJev.jsx'; 12import { useBooth } from '../../../jev/JevBooth.jsx'; 13import { loadTally, tallyFor, tallyStore } from '../../../jev/reactions.mjs'; 14import { LISTENER_PREFIX, listenerParam, listenerSongs, loadListenerSong } from '../../../jev/listeners.mjs'; 15import { 16 Comments, 17 ListenerSongView, 18 MyContent, 19 Pitches, 20 PublishSong, 21 useListenerSongs, 22} from '../../../jev/Listeners.jsx'; 23 24// jevstrudel: the welcome tab ("jev") is the song browser. The songs are 25// indexed at build time (src/jev/songs.mjs) and embedded in the REPL page as 26// JSON. A card plays its song and opens it in place of the list, with a 27// breadcrumb back; the song shown is whatever is playing, so the radio moves 28// the page along with it. Below the site's songs are listeners' songs 29// (jev/Listeners.jsx), opened and played the same way, and every song's 30// view has its comments. 31 32// Every listener's 🔥/😴 for the song, per section (jev/reactions.mjs): in 33// the order the song is written once it has been played here (its form's 34// sections; other names are no section of it), else most reacted first. 35const MAX_TALLY_SECTIONS = 24; 36function ListenerTally({ song }) { 37 useSyncExternalStore(tallyStore.subscribe, tallyStore.version, tallyStore.version); 38 const booth = useBooth(); 39 useEffect(() => { 40 if (!tallyFor(song.id)) loadTally(song.id); 41 }, [song.id]); 42 const tally = tallyFor(song.id); 43 if (!tally) return null; 44 const written = nowPlaying.get() === song.id ? booth?.form?.sections : null; 45 const total = (n) => tally[n].fire + tally[n].sleep; 46 const names = (written ? written.filter((n) => Object.hasOwn(tally, n)) : Object.keys(tally).sort((a, b) => total(b) - total(a))) 47 .filter((n) => total(n) > 0) 48 .slice(0, MAX_TALLY_SECTIONS); 49 if (!names.length) return null; 50 return ( 51 <div className="not-prose text-sm mb-3" data-listener-tally> 52 <p className="mb-1">What every listener said, per section (Jev hears it too):</p> 53 <div className="flex flex-wrap gap-x-3 gap-y-1 text-xs font-mono"> 54 {names.map((n) => ( 55 <span key={n}> 56 {n} 🔥{tally[n].fire} 😴{tally[n].sleep} 57 </span> 58 ))} 59 </div> 60 </div> 61 ); 62} 63 64function SongSpec({ song }) { 65 return ( 66 <div> 67 <h2 className="mb-1">{song.title}</h2> 68 {song.art && ( 69 <div className="not-prose text-sm mb-3"> 70 <p className="mb-1"> 71 Is it art? Jev (<span className="font-mono">{song.art.model}</span>) says{' '} 72 <strong>{artPercent(song.art)}%</strong> 73 {song.art.criteria && ( 74 <span className="text-xs opacity-70"> 75 {' '} 76 — the mean of six criteria scored like a harsh critic, out of 5: 3 is good, 5 the best ever made in its 77 genre. Three notes scores 2%. 78 </span> 79 )} 80 </p> 81 <ArtBreakdown criteria={song.art.criteria} /> 82 </div> 83 )} 84 {/* the Worker keeps tallies only for site songs (worker/src/listening.ts) */} 85 {!song.id.startsWith(LISTENER_PREFIX) && <ListenerTally song={song} />} 86 {/* Jev's takes and how it moves through the song: site songs only, like the tally */} 87 {!song.id.startsWith(LISTENER_PREFIX) && <JevTakes song={song} />} 88 {song.revisions.length > 0 && ( 89 <details className="not-prose text-xs opacity-75 mb-2"> 90 <summary className="cursor-pointer"> 91 {song.revisions.length} revision{song.revisions.length > 1 ? 's' : ''} ·{' '} 92 {[...new Set(song.revisions.map((r) => r.model))].join(', ')} 93 <ArtTrend revisions={song.revisions} /> 94 </summary> 95 <ol className="list-decimal pl-5 space-y-1 mt-1"> 96 {song.revisions.map((r) => ( 97 <li key={r.rev}> 98 <span className="font-mono">{r.date}</span> · <span className="font-mono">{r.model}</span> ·{' '} 99 {`"${r.prompt}"${r.output ? ` → ${r.output}` : ''}`} ·{' '} 100 {r.art === null ? ( 101 <span title="its code could not be recovered">not scored</span> 102 ) : ( 103 `${artPercent({ p: r.art })}% art` 104 )} 105 </li> 106 ))} 107 </ol> 108 </details> 109 )} 110 {/* our own SPEC.md, compiled by Astro at build time */} 111 <div dangerouslySetInnerHTML={{ __html: song.specHtml }} /> 112 </div> 113 ); 114} 115 116// "rev 1 91% → rev 4 94% (+3)": the first and last scored revisions, when 117// there are two to compare. 118function ArtTrend({ revisions }) { 119 const scored = revisions.filter((r) => r.art !== null); 120 if (scored.length < 2) return null; 121 const [first, last] = [scored[0], scored[scored.length - 1]]; 122 const [a, b] = [artPercent({ p: first.art }), artPercent({ p: last.art })]; 123 const change = b - a; 124 return ( 125 <> 126 {' '} 127 · art: rev {first.rev} {a}% → rev {last.rev} {b}% ({change > 0 ? `+${change}` : change === 0 ? '±0' : change}) 128 </> 129 ); 130} 131 132function formatDuration(seconds) { 133 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; 134} 135 136function artPercent(art) { 137 return Math.round(art.p * 100); 138} 139 140// A song's card. The whole card plays it (a button stretched over it, so 141// Enter and Space do too, and it reads as the song's title), and the 142// author's name opens their profile: two controls side by side, never one 143// inside the other, since a button cannot hold another. 144export function SongCard({ song, selected, onSelect }) { 145 // the breakdown shows only while the pointer is on the "N% art" pill 146 const [showArt, setShowArt] = useState(false); 147 const playable = Boolean(song.code || song.listenerId); 148 return ( 149 <div 150 data-testid="jev-song-card" 151 className={cx( 152 'relative flex flex-col text-left rounded-md overflow-hidden border bg-lineHighlight', 153 playable && 'hover:opacity-80', 154 selected ? 'border-foreground' : 'border-muted', 155 !playable && 'opacity-50', 156 )} 157 > 158 <button 159 type="button" 160 onClick={onSelect} 161 disabled={!playable} 162 aria-label={song.title} 163 className="absolute inset-0 z-0 cursor-pointer disabled:cursor-default focus-visible:outline focus-visible:outline-2 focus-visible:outline-foreground" 164 /> 165 {/* drawn over the button, letting clicks through to it but for the art pill and the name */} 166 <div className="relative pointer-events-none"> 167 {song.cover ? ( 168 <img src={song.cover} alt={song.coverAlt ?? ''} loading="lazy" className="w-full aspect-square object-cover" /> 169 ) : ( 170 <div className="w-full aspect-square flex items-center justify-center text-2xl opacity-25">♪</div> 171 )} 172 {song.art && ( 173 <div 174 onMouseEnter={() => setShowArt(true)} 175 onMouseLeave={() => setShowArt(false)} 176 onClick={playable ? onSelect : undefined} 177 className="pointer-events-auto absolute z-10 top-1 right-1 rounded px-1.5 py-0.5 text-xs font-bold font-mono bg-black/75 text-white cursor-help" 178 > 179 {artPercent(song.art)}% art 180 </div> 181 )} 182 {song.art?.criteria && showArt && ( 183 <div className="absolute inset-0 flex flex-col justify-center gap-1 p-2 bg-black/85 text-white text-[10px] font-mono"> 184 <div className="font-bold"> 185 {artPercent(song.art)}% art, says {song.art.model} 186 </div> 187 <ArtBreakdown criteria={song.art.criteria} compact /> 188 </div> 189 )} 190 </div> 191 <div className="relative p-2 space-y-1 pointer-events-none"> 192 <div className="font-bold leading-tight">{song.title}</div> 193 <div className="text-xs opacity-60 font-mono"> 194 {song.author ? ( 195 <> 196 by <NameLink id={song.authorId} name={song.author} className="pointer-events-auto relative z-10" /> 197 </> 198 ) : song.timing ? ( 199 `${song.timing.jev ? "Jev's call" : formatDuration(song.timing.seconds)} · ${song.timing.bpm} bpm` 200 ) : ( 201 'planned' 202 )} 203 </div> 204 {song.description && <div className="text-xs opacity-80 line-clamp-4">{song.description}</div>} 205 </div> 206 </div> 207 ); 208} 209 210export function WelcomeTab({ context }) { 211 const { fontFamily } = useSettings(); 212 const songs = readSongs(); 213 const listened = useListenerSongs(); 214 // the song loaded last, by a card, the mood picker or the radio 215 const selectedId = useSyncExternalStore(nowPlaying.subscribe, nowPlaying.get, nowPlaying.get); 216 const selected = songs.find((s) => s.id === selectedId) ?? listenerSongs.get(selectedId); 217 const themes = [...new Set(songs.map((s) => s.theme))]; 218 219 // a song's own page, or a listener song's link, opens on that song; the home page on the list 220 const [open, setOpen] = useState(() => songPageId() !== null || listenerParam() !== null); 221 const [loading, setLoading] = useState(null); // { title } while a listener song is fetched, or { error } 222 // the panel scrolls, and keeps the list's position: start each view at its top 223 const top = useRef(null); 224 const showing = open && selected ? selected.id : null; 225 useLayoutEffect(() => { 226 top.current?.scrollIntoView({ block: 'start' }); 227 }, [showing]); 228 229 // a listener song's card has no code: the song is fetched whole, then 230 // plays in the sandbox (jev/sandbox.mjs), never in this page 231 const openListenerSong = async (card) => { 232 setLoading({ title: card.title }); 233 try { 234 const song = await loadListenerSong(card.listenerId); 235 setLoading(null); 236 setOpen(true); 237 await showListenerSong(context.editorRef.current, song, { play: true }); 238 } catch (e) { 239 setLoading({ error: `${card.title} could not be loaded: ${e.message}` }); 240 } 241 }; 242 243 if (open && selected) 244 return ( 245 <div ref={top} className="prose dark:prose-invert min-w-full py-4 font-sans px-4 text-sm" style={{ fontFamily }}> 246 <nav aria-label="breadcrumb" className="not-prose text-sm mb-3 flex items-center gap-2"> 247 <button className="underline hover:opacity-50" onClick={() => setOpen(false)}> 248 jev 249 </button> 250 <span className="opacity-50">›</span> 251 {selected.listenerId && ( 252 <> 253 <button className="underline hover:opacity-50" onClick={() => setOpen(false)}> 254 listeners 255 </button> 256 <span className="opacity-50">›</span> 257 </> 258 )} 259 <span className="font-bold">{selected.title}</span> 260 </nav> 261 {/* the song's own card, as in the list: clicking it plays it from the top */} 262 <div className="not-prose mb-4" style={{ maxWidth: '14rem' }}> 263 <SongCard 264 song={selected} 265 selected 266 onSelect={() => 267 selected.listenerId ? openListenerSong(selected) : playSong(context.editorRef.current, selected) 268 } 269 /> 270 </div> 271 {/* what people say first, then who is listening, then the song itself */} 272 <Comments song={selected.id} /> 273 <SongListening song={selected} context={context} /> 274 {selected.listenerId ? <ListenerSongView song={selected} context={context} /> : <SongSpec song={selected} />} 275 </div> 276 ); 277 278 return ( 279 <div ref={top} className="prose dark:prose-invert min-w-full py-4 font-sans px-4 text-sm" style={{ fontFamily }}> 280 <h3>jevstrudel</h3> 281 <p> 282 Songs vibe-coded in strudel. Pick one: it opens here, its code loads into the editor, and it plays from the top. 283 </p> 284 <MoodPicker context={context} /> 285 <RadioToggle /> 286 <AgreeWithJev /> 287 {themes.map((theme) => ( 288 <div key={theme}> 289 <h4>{theme}</h4> 290 <div 291 className="not-prose grid gap-3" 292 style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(10rem, 1fr))' }} 293 > 294 {songs 295 .filter((s) => s.theme === theme) 296 .map((song) => ( 297 <SongCard 298 key={song.id} 299 song={song} 300 selected={song.id === selectedId} 301 onSelect={() => { 302 setOpen(true); 303 playSong(context.editorRef.current, song); 304 }} 305 /> 306 ))} 307 </div> 308 </div> 309 ))} 310 <div data-listeners> 311 <h4>listeners</h4> 312 <p> 313 Songs listeners published, most art first by the same critic. Jev reads everything before it is shown here. A 314 listener's song is their code: it plays in a sandbox, apart from this page and your account. 315 </p> 316 <PublishSong context={context} /> 317 <MyContent /> 318 {listened === null ? ( 319 <p className="text-xs opacity-70">Listeners' songs are not available here.</p> 320 ) : listened.length === 0 ? ( 321 <p className="text-xs opacity-70">None yet.</p> 322 ) : ( 323 <div 324 className="not-prose grid gap-3" 325 style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(10rem, 1fr))' }} 326 > 327 {listened.map((song) => ( 328 <SongCard 329 key={song.id} 330 song={song} 331 selected={song.id === selectedId} 332 onSelect={() => openListenerSong(song)} 333 /> 334 ))} 335 </div> 336 )} 337 {loading && <p className="text-xs opacity-70">{loading.error ?? `loading ${loading.title}…`}</p>} 338 <Pitches /> 339 </div> 340 <hr /> 341 <h3>about strudel</h3> 342 <p> 343 jevstrudel is built on{' '} 344 <a href="https://strudel.cc" target="_blank"> 345 strudel 346 </a> 347 , a JavaScript version of{' '} 348 <a href="https://tidalcycles.org/" target="_blank"> 349 tidalcycles 350 </a> 351 . Strudel is free/open source software, with copyright owned by its{' '} 352 <a href="https://codeberg.org/uzu/strudel/activity/contributors" target="_blank"> 353 contributors 354 </a> 355 . You can redistribute and/or modify it under the terms of the{' '} 356 <a href="https://codeberg.org/uzu/strudel/src/branch/main/LICENSE" target="_blank"> 357 GNU Affero General Public License 358 </a> 359 . Its source is on{' '} 360 <a href="https://codeberg.org/uzu/strudel" target="_blank"> 361 codeberg 362 </a> 363 , and <a href="https://github.com/felixroos/dough-samples/blob/main/README.md">licensing info</a> for the 364 default sound banks is there too. 365 </p> 366 </div> 367 ); 368}