jevstrudel.git / website / src / jev / JevPerformance.jsx
JevPerformance.jsx155 lines · 6.2 KB · raw
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}