jevstrudel.git / website / src / jev / History.jsx
History.jsx107 lines · 4.6 KB · raw
1// What a listener played lately (profile.mjs's playedLately): the you tab's
2// "recently played" for the signed-in listener, and a profile's "played
3// lately" (Profile.jsx), from the account's page (GET
4// /jev/data/accounts/<id>). Each take shows when it was played, its path
5// through the song's form and ▶ replay, its own replay link
6// (performance.mjs: a listener song's replays in the sandbox); a radio play
7// with no take shows when it started. Every song plays again as its card
8// plays it: a site song from this page's build, a listener's in the sandbox.
9import { useEffect, useState, useSyncExternalStore } from 'react';
10import { account } from './account.mjs';
11import { loadListenerSong as showListenerSong, playSong, readSongs } from './client.mjs';
12import { LISTENER_PREFIX, loadListenerSong } from './listeners.mjs';
13import { performancePath } from './performance.mjs';
14import { loadProfile, playedLately } from './profile.mjs';
15import { takeTime } from './takes.mjs';
16
17const when = (ms) => new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });
18export const SHOWN = 20;
19
20// A song id's title: a site song's from this build, a listener song's as
21// the Worker named it, else what it is.
22function titleOf(entry, songs) {
23  if (entry.song.startsWith(LISTENER_PREFIX)) return entry.title ?? 'a listener’s song';
24  return songs.find((s) => s.id === entry.song)?.title ?? entry.song;
25}
26
27export function PlayedList({ entries, context, empty }) {
28  const [error, setError] = useState(null);
29  const songs = readSongs();
30  const editor = context.editorRef.current;
31  const play = async (songId) => {
32    setError(null);
33    if (songId.startsWith(LISTENER_PREFIX)) {
34      try {
35        await showListenerSong(editor, await loadListenerSong(songId.slice(LISTENER_PREFIX.length)), { play: true });
36      } catch (e) {
37        setError(`that song could not be loaded: ${e.message}`);
38      }
39      return;
40    }
41    const song = songs.find((s) => s.id === songId);
42    if (song?.code) playSong(editor, song);
43  };
44  if (!entries.length) return <p className="opacity-60">{empty}</p>;
45  return (
46    <>
47      {error && <p className="text-red-400 text-xs">{error}</p>}
48      <ul className="space-y-1" data-testid="jev-played">
49        {entries.map((e) => {
50          const playable = e.song.startsWith(LISTENER_PREFIX) || songs.some((s) => s.id === e.song && s.code);
51          return (
52            <li key={e.take?.id ?? `${e.song}@${e.at}`} className="flex flex-wrap gap-x-2 items-baseline">
53              <button
54                className="underline hover:opacity-60 text-left disabled:no-underline disabled:opacity-50"
55                disabled={!playable}
56                title={playable ? 'play it again, from the top' : 'no longer on the site'}
57                onClick={() => play(e.song)}
58              >
59                {titleOf(e, songs)}
60              </button>
61              <span className="opacity-60 text-xs">{e.take ? takeTime(e.take) : when(e.at)}</span>
62              {e.take && (
63                <a
64                  className="text-xs underline opacity-70 hover:opacity-100"
65                  href={performancePath(e.song, e.take.id)}
66                  title="replay this take: Jev's decisions as they were, with no Jev calls"
67                >
68                  ▶ replay
69                </a>
70              )}
71              {e.take?.path && <span className="basis-full text-xs opacity-60 break-words">{e.take.path.join(' → ')}</span>}
72            </li>
73          );
74        })}
75      </ul>
76    </>
77  );
78}
79
80export function RecentlyPlayed({ context }) {
81  const { user } = useSyncExternalStore(account.subscribe, account.get, account.get);
82  const [entries, setEntries] = useState(null);
83  const [error, setError] = useState(null);
84  useEffect(() => {
85    if (!user) return setEntries(null);
86    let live = true;
87    setError(null);
88    loadProfile(user.id).then(
89      (a) => live && setEntries(playedLately(a.radio, a.takes, SHOWN)),
90      (e) => live && setError(e.message),
91    );
92    return () => void (live = false);
93  }, [user?.id]);
94  if (!user) {
95    return <p className="opacity-70">Sign in, and what you play shows here, on every device, each take with its replay.</p>;
96  }
97  return (
98    <div data-testid="jev-history">
99      <h4 className="font-bold mb-1">recently played</h4>
100      {error && <p className="text-red-400 text-xs">could not read it: {error}</p>}
101      {entries === null && !error && <p className="opacity-60">reading…</p>}
102      {entries && (
103        <PlayedList entries={entries} context={context} empty="Nothing yet: play a song, turn on autoplay, or press ⏭." />
104      )}
105    </div>
106  );
107}