jevstrudel.git / website / src / jev / JevPicks.jsx
JevPicks.jsx278 lines · 10.7 KB · raw
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}