jevstrudel.git / website / src / jev / client.mjs
1import { siteName } from './site.mjs';
2import { codeHash, endReplay, fetchPerformance, PERFORMANCE_PARAM } from './performance.mjs';
3import { LISTENER_PARAM, listenerParam } from './listeners.mjs';
4import { armSandboxReplay, playInSandbox, setOwnCode, showForeign } from './sandbox.mjs';
5
6// The browser side of the song index: index.astro embeds loadSongs() as
7// JSON in #jev-songs, and the REPL reads it back from the page.
8
9// The song the home page opens on: selected on the jev panel, loaded into
10// the editor, and played once audio is allowed (see useReplContext).
11export const featuredSongId = 'jev/lightning-in-a-bottle';
12
13let songsCache;
14export function readSongs() {
15  if (songsCache === undefined) {
16    const el = typeof document !== 'undefined' && document.getElementById('jev-songs');
17    songsCache = el ? JSON.parse(el.textContent) : [];
18  }
19  return songsCache;
20}
21
22// The song this page opens on: a song's own page (/songs/<theme>/<song>/)
23// names it in a jev-song meta tag (ReplPage.astro); the home page opens on
24// the featured song.
25export function songPageId() {
26  const meta = typeof document !== 'undefined' && document.querySelector('meta[name="jev-song"]');
27  return meta?.content || null;
28}
29export function startSongId() {
30  return songPageId() ?? featuredSongId;
31}
32
33// A listener's song link (/?listener=<id>) opens on that song instead,
34// fetched from the Worker when the editor starts (useReplContext).
35export function startSong() {
36  if (!songPageId() && listenerParam()) return undefined;
37  const id = startSongId();
38  return readSongs().find((s) => s.id === id && s.code);
39}
40
41// The song in the editor, as the song browser, the radio and "is it art?"
42// see it: set when a song is loaded, and read against the editor's code, so
43// an edited song still counts as that song.
44let playingId = null;
45const playingListeners = new Set();
46export const nowPlaying = {
47  subscribe(listener) {
48    playingListeners.add(listener);
49    return () => playingListeners.delete(listener);
50  },
51  get: () => playingId,
52  set(id) {
53    playingId = id;
54    for (const listener of playingListeners) listener();
55  },
56};
57
58// Songs are arranged with masks that count cycles from 0, so they must start
59// from stopped: the scheduler's stop() resets the cycle count, pause() does not.
60// A song picked here plays live: a performance being replayed ends
61// (performance.mjs). Only the site's own songs: a listener's has no `code`
62// (listeners.mjs) and plays in the sandbox (sandbox.mjs, playListenerSong).
63export async function playSong(editor, song) {
64  if (!editor || !song.code) return;
65  endReplay();
66  showSong(song);
67  nowPlaying.set(song.id);
68  await editor.stop();
69  setOwnCode(editor, song.code);
70  await editor.evaluate();
71}
72
73// A listener's song (listeners.mjs): its code into the editor as foreign,
74// so it is evaluated only in the sandbox (sandbox.mjs), and, with `play`,
75// played there. With `replay`, a recorded performance's id: the sandbox's
76// jev()s play that take and ask Jev nothing, still in the sandbox (the
77// frame arms it; sandbox.mjs's armSandboxReplay). Resolves to { replaying:
78// { changed } } when it replays, or { error } when that take could not be
79// loaded (Jev then plays the song live), else {}.
80export async function loadListenerSong(editor, song, { play = false, replay = null } = {}) {
81  if (!editor || !song.foreignCode) return {};
82  endReplay();
83  let replaying = null;
84  let error;
85  if (replay) {
86    try {
87      const performance = await fetchPerformance(replay);
88      if (performance.song !== song.id) throw new Error(`that take is of ${performance.song}`);
89      replaying = { jevs: performance.jevs, changed: performance.codeHash !== (await codeHash(song.foreignCode)) };
90    } catch (e) {
91      error = e.message;
92    }
93  }
94  showSong(song);
95  nowPlaying.set(song.id);
96  await editor.stop();
97  // `code`: the song as written, so the recorder knows an edited one (JevPerformance.jsx)
98  showForeign(editor, song.foreignCode, { song: { title: song.title, author: song.author, id: song.id, code: song.foreignCode } });
99  armSandboxReplay(song.id, replaying);
100  if (play) await playInSandbox(song.foreignCode);
101  return replaying ? { replaying: { changed: replaying.changed } } : error ? { error } : {};
102}
103
104// A song's shareable address. Picking a song puts it in the address bar
105// (keeping the query, e.g. ?session=, but not a replayed ?performance=,
106// which that song does not play) and the tab title, so the link to copy is
107// always the song that is playing. A listener's song has no page of its
108// own (the site is built before it exists): its address is the home page
109// with ?listener=<id>.
110export const songPath = (song) => `/songs/${song.id}/`;
111export function showSong(song) {
112  document.title = `${song.title} - ${siteName}`;
113  try {
114    const query = new URLSearchParams(location.search);
115    query.delete(PERFORMANCE_PARAM);
116    query.delete(LISTENER_PARAM);
117    if (song.listenerId) query.set(LISTENER_PARAM, song.listenerId);
118    const search = query.size ? `?${query}` : '';
119    history.replaceState(history.state, '', (song.listenerId ? '/' : songPath(song)) + search);
120  } catch {
121    // an embedded or sandboxed page may refuse; the song still plays
122  }
123}
124
125// Whether the song the page opens on plays by itself. Never on a reload:
126// the PWA's "update" reloads the page, and a song the visitor had stopped
127// must not start again on their next click. A song's own page is a link to
128// that song, so it plays whenever it is opened; the home page's featured
129// song plays once per tab (and the tools mark it played to keep it quiet).
130const AUTOPLAYED = 'jevstrudel.autoplayed';
131const reloaded = () => performance.getEntriesByType?.('navigation')[0]?.type === 'reload';
132export function takeAutoplay() {
133  if (reloaded()) return false;
134  if (songPageId()) return true;
135  try {
136    if (sessionStorage.getItem(AUTOPLAYED)) return false;
137    sessionStorage.setItem(AUTOPLAYED, '1');
138  } catch {
139    // no storage: play, as a first visit would
140  }
141  return true;
142}