1// Recorded performances in the page (performance.mjs): 2// 3// - PerformanceRecorder (always mounted, beside the radio): follows each 4// evaluation of a song as written, and stores its performance once, when 5// it ends, or when it is stopped or replaced after its opening section. 6// A site song plays in the page; a listener's song ("listener:<id>") 7// plays in the sandbox, and its booth is the frame's, rebuilt by 8// sandboxProtocol.mjs, which the Worker checks again. A replay, an edited 9// song, and code that is no song are not recorded. 10// - PerformanceShare (in the booth): once stored, "share this performance" 11// copies its link; while replaying, says so, and whether the song has 12// changed since. 13import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; 14import { nowPlaying, readSongs } from './client.mjs'; 15import { LISTENER_PREFIX } from './listeners.mjs'; 16import { sandbox } from './sandbox.mjs'; 17import { useBooth, usePlayhead } from './JevBooth.jsx'; 18import { codeHash, performanceOf, performancePath, postPerformance, recordingStore } from './performance.mjs'; 19import { themeColor as ACCENT } from './site.mjs'; 20 21// The same evaluation's booth is recombined on every change; its first 22// view stays (JevBooth.jsx). 23const identityOf = (booth) => booth?.views?.[0] ?? booth; 24 25async function store(t, ended) { 26 t.done = true; 27 const upTo = ended === 'finished' ? t.booth.last : Math.min(t.reached, t.booth.last ?? Infinity); 28 try { 29 const body = performanceOf(t.booth, { song: t.song.id, codeHash: await codeHash(t.song.code), ended, upTo }); 30 if (!body) return; 31 recordingStore.set({ identity: t.identity, status: 'saving' }); 32 const id = await postPerformance(body); 33 recordingStore.set({ identity: t.identity, status: 'saved', path: performancePath(t.song.id, id) }); 34 } catch (e) { 35 recordingStore.set({ identity: t.identity, status: 'failed', error: e.message }); 36 } 37} 38 39// The song a booth plays, with the code it was written as: a site song 40// from this page's build, or in the sandbox the listener's song the 41// editor holds (sandbox.mjs's foreign, set by client.mjs's loadListenerSong). 42function songOf(booth) { 43 if (booth.sandboxed) { 44 const song = sandbox.foreign()?.song; 45 return song?.id?.startsWith(LISTENER_PREFIX) && typeof song.code === 'string' ? { id: song.id, code: song.code } : null; 46 } 47 return readSongs().find((s) => s.id === nowPlaying.get()) ?? null; 48} 49 50// Whether `t` has a performance worth keeping: a song as written, not a 51// replay, that ended or got past its opening section. 52const worthKeeping = (t, ended) => 53 t && 54 !t.done && 55 t.song && 56 !t.booth.replay && 57 // the frame's jev()s apart, as they are recorded (a booth whose `jevs` 58 // sandboxProtocol.mjs refused has none, and is not recorded as one) 59 (!t.booth.sandboxed || Array.isArray(t.booth.jevs)) && 60 (ended === 'finished' || t.reached >= 1); 61 62export function PerformanceRecorder({ context }) { 63 const booth = useBooth(); 64 const cycle = usePlayhead(context.editorRef, context.started); 65 const track = useRef(null); 66 const identity = identityOf(booth); 67 68 // A new evaluation: the last one is over (stopped or replaced); follow this one. 69 useEffect(() => { 70 const last = track.current; 71 if (last && last.identity !== identity && worthKeeping(last, 'stopped')) store(last, 'stopped'); 72 if (!booth) { 73 track.current = null; 74 return; 75 } 76 if (last?.identity === identity) return; 77 const song = songOf(booth); 78 // only a song as written: an edited one is the listener's own 79 const asWritten = song && context.editorRef.current?.code === song.code; 80 track.current = { identity, booth, song: asWritten ? song : null, reached: -1, done: false }; 81 }, [identity]); 82 if (track.current?.identity === identity) track.current.booth = booth; 83 84 useEffect(() => { 85 const t = track.current; 86 if (!t || !context.started || cycle === null || cycle < 0) return; 87 t.reached = Math.max(t.reached, Math.floor(cycle / t.booth.every)); 88 const { last, every } = t.booth; 89 if (last !== null && cycle >= (last + 1) * every && worthKeeping(t, 'finished')) store(t, 'finished'); 90 }, [cycle, context.started]); 91 92 useEffect(() => { 93 const t = track.current; 94 if (!context.started && worthKeeping(t, 'stopped')) store(t, 'stopped'); 95 }, [context.started]); 96 97 return null; 98} 99 100export function PerformanceShare({ booth }) { 101 const recording = useSyncExternalStore(recordingStore.subscribe, recordingStore.get, () => null); 102 const [copied, setCopied] = useState(false); 103 useEffect(() => setCopied(false), [recording]); 104 105 if (booth.replay) { 106 return ( 107 <span 108 className="text-xs whitespace-nowrap" 109 style={{ color: ACCENT }} 110 title="every answer is the recorded performance's; Jev is not asked" 111 > 112 ▶ replaying a performance 113 {booth.replay.changed && <span className="opacity-70"> · the song has changed since; it plays where it still can</span>} 114 </span> 115 ); 116 } 117 if (!recording || recording.identity !== identityOf(booth)) return null; 118 if (recording.status === 'saving') return <span className="text-xs opacity-60 whitespace-nowrap">saving the performance…</span>; 119 if (recording.status === 'failed') { 120 return ( 121 <span className="text-xs text-red-400 whitespace-nowrap" title={recording.error}> 122 could not save the performance 123 </span> 124 ); 125 } 126 const url = `${location.origin}${recording.path}`; 127 const share = async () => { 128 try { 129 await navigator.clipboard.writeText(url); 130 setCopied(true); 131 } catch { 132 // no clipboard: the link itself is shown 133 setCopied(null); 134 } 135 }; 136 return ( 137 <span className="flex items-center gap-2 text-xs whitespace-nowrap"> 138 <button 139 className="rounded border px-1 hover:opacity-70" 140 style={{ borderColor: ACCENT, color: ACCENT }} 141 onClick={share} 142 data-performance-link={url} 143 title="a link that replays this performance: the same sections, levels and choices" 144 > 145 🔗 share this performance 146 </button> 147 {copied && <span className="opacity-70">link copied</span>} 148 {copied === null && ( 149 <a className="underline truncate max-w-64" href={url}> 150 {url} 151 </a> 152 )} 153 </span> 154 ); 155}