jevstrudel.git / website / src / jev / nextTrack.mjs

The next track: what autoplay plays when a song ends, and what the ⏭ button beside play plays at once (JevPicks.jsx). One path for both, so they hear the same history and record the same plays.

Jev picks from the listener's history (picker.mjs, pickNext): signed in, the account's radio history across sessions and devices (radioHistory.mjs), whose most recent songs are not offered at all, and each pick is recorded there; signed out, the titles this tab has played. When Jev cannot answer, the next song in the list plays.

Takes the page's functions as arguments, so it runs in vitest.

12const HEARD = 8;

The cycle a song as written ends on, or null while not known. Songs end cycles, not seconds, after they start: a song whose form Jev walks ends after the section Jev ends it on; any other song after the bars its play-once mask gives it (songs.mjs). A couple of cycles more let the final chord ring. Autoplay moves on there; the lobby shows progress to it.

19const RING_OUT = 2;
20export function endCycle(song, booth) {
21  if (booth && booth.last !== null && (song.timing?.jev || Number.isFinite(booth.segments))) {
22    return (booth.last + 1) * booth.every + RING_OUT;
23  }
24  if (song.timing?.jev) return null; // Jev has not ended it yet
25  return song.timing ? song.timing.bars + RING_OUT : null;
26}
28export function nextTracker({ pick, play, loadRecent, record }) {
29  let heardHere = []; // titles this tab played, oldest first
30  let recent = null; // signed in: the account's song ids, newest first
31  let recentFor = null; // whose `recent` it is
32  let busy = false;
33
34  return {
35    // `current` is the site song playing as written, or null; `user` who is
36    // signed in, or null. Resolves to { song, p, error } once `song` plays,
37    // or null when a pick is already under way.
38    async next({ songs, current, user, editor }) {
39      if (busy) return null;
40      busy = true;
41      try {
42        if (!user) [recent, recentFor] = [null, null];
43        else if (recentFor !== user.id) [recent, recentFor] = [await loadRecent(), user.id];
44        if (current) heardHere = [...heardHere, current.title].slice(-HEARD);
45        const heard = recent
46          ? recent
47              .slice(0, HEARD)
48              .reverse()
49              .map((id) => songs.find((s) => s.id === id)?.title)
50              .filter(Boolean)
51          : heardHere;
52        let song;
53        let p;
54        let error = null;
55        try {
56          ({ song, p } = await pick(songs, current, heard, recent ? { recent } : {}));
57        } catch (e) {
58          const playable = songs.filter((s) => s.code);
59          song = playable[(playable.findIndex((s) => s.id === current?.id) + 1) % playable.length];
60          error = e.message;
61        }
62        await play(editor, song);
63        if (recent) {
64          recent = [song.id, ...recent];
65          record(song.id);
66        }
67        return { song, p, error };
68      } finally {
69        busy = false;
70      }
71    },
72  };
73}