jevstrudel.git / website / src / jev / nextTrack.mjs
1// The next track: what autoplay plays when a song ends, and what the ⏭
2// button beside play plays at once (JevPicks.jsx). One path for both, so
3// they hear the same history and record the same plays.
4//
5// Jev picks from the listener's history (picker.mjs, pickNext): signed in,
6// the account's radio history across sessions and devices
7// (radioHistory.mjs), whose most recent songs are not offered at all, and
8// each pick is recorded there; signed out, the titles this tab has played.
9// When Jev cannot answer, the next song in the list plays.
10//
11// Takes the page's functions as arguments, so it runs in vitest.
12const HEARD = 8;
13
14// The cycle a song as written ends on, or null while not known. Songs end
15// cycles, not seconds, after they start: a song whose form Jev walks ends
16// after the section Jev ends it on; any other song after the bars its
17// play-once mask gives it (songs.mjs). A couple of cycles more let the
18// 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}
27
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}